VII Patterns

A Wallet and Its Ledger

Customers of the shop keep money in it: they top up, pay for orders from the balance, and get refunds back into it. Finance wants every movement as a line that is never rewritten, and the lines must add up to the balance — always, not “after the nightly job”.

Three naive designs, and what breaks each:

  • Compute the balance by adding up the lines. A payment reads the lines, sees 5000, and inserts “−4000”. Two payments at the same moment both read 5000 and both insert. The customer spent 8000 of 5000. Each insert is a new object, so nothing conflicts and nothing stops it.
  • Change the balance, then call the ledger to write the line. The process stops between the two. The balance moved and no line says why; the books never add up again.
  • Just subtract. The request that tops up is retried by a client that saw no answer, or the event that pays is delivered twice. The money moves twice.

§The wallet

One object per customer — the customer’s wallet in billing.wallets — holds the number, and every change of it is one write of that object. This is the whole component:

# components/billing.wallets/manifest.toa.yaml
name: wallets
namespace: billing

entity:
  associated: true
  properties:
    balance: &amount
      type: integer        # minor units: cents, never a fraction
      minimum: 0
    held: *amount          # set aside for orders not yet shipped
    paid:                  # what was taken, under what it was for
      type: object
      additionalProperties: { type: integer, minimum: 1 }
  required: [balance, held]
  blank:
    balance: 0
    held: 0
    paid: {}

operations:
  credit:
    concurrency: retry
    once: true
    query: true
    input:
      type: object
      properties:
        amount: { type: integer, minimum: 1 }
        type: { type: string, enum: [topup, refund, goodwill] }
        ref: { type: string }      # what it was for: a checkout, an order
      required: [amount, type, ref]
  debit:
    concurrency: retry
    query: true
    errors: [INSUFFICIENT_FUNDS]
    input:
      type: object
      properties:
        amount: { type: integer, minimum: 1 }
        ref: { type: string }      # what it is for, and one debit per ref
      required: [amount, ref]

receivers:
  billing.topups.paid: credit
// components/billing.wallets/operations/credit.ts
import type { CreditInput, State } from '../types/index.d.ts'

export async function transition (input: CreditInput, entry: State) {
  entry.balance += input.amount

  entry.TRAILERS.amount = input.amount
  entry.TRAILERS.type = input.type
  entry.TRAILERS.ref = input.ref

  return entry
}
// components/billing.wallets/operations/debit.ts
import type { DebitInput, State } from '../types/index.d.ts'

export async function transition (input: DebitInput, entry: State) {
  if (entry.paid[input.ref] !== undefined) {
    entry.DISCARD = true   // asked again for the same thing: the money is taken

    return entry
  }

  if (entry.balance - entry.held < input.amount) return new Error('INSUFFICIENT_FUNDS')

  entry.balance -= input.amount
  entry.paid[input.ref] = input.amount

  entry.TRAILERS.amount = -input.amount
  entry.TRAILERS.type = 'payment'
  entry.TRAILERS.ref = input.ref

  return entry
}
// components/billing.wallets/guards/held.ts
import type { Entity } from '../types/index.d.ts'

export function guard (wallet: Entity) {
  return wallet.held <= wallet.balance
}

billing.topups.paid, which the receiver binds to credit, announces a payment recorded once; the event and the receiver’s file are in Receiving Webhooks.

Each line of the manifest answers one of the failures:

  • associated, with a blank. The wallet’s id is the customer’s id. A customer who never topped up has a wallet that reads balance: 0; there is no “create a wallet” step to forget or to fail halfway (Keep Data About a Customer without Creating It). The wallet with a balance and nothing else is that of Describe State.
  • The balance is stored, and changed by a transition with concurrency: retry. The check “is there enough” and the subtraction happen against one object in one write. Of two payments at once, one is stored; the other is run again against the balance the first one left, and is refused if that is too little (Two Writers at Once).
  • once: true on credit. The same call arriving again — a redelivered event, a client’s retry — moves nothing and is answered as the first time (Handle Repeated Calls).
  • paid, and no once on debit. A debit is for something, and says what in ref. The wallet keeps what it took under it, and a second debit for the same thing stores nothing and answers the wallet, as the first did. Why the two differ is below.
  • minimum: 0 keeps the balance from going negative whatever operation writes it, and the guard keeps reserved money inside the balance. held is changed by the hold and release operations of Reservations; here it only matters that debit spends from balance - held.
  • Integers. An amount is a whole number of the smallest unit. A float drifts by a cent sooner or later.

§One debit per ref

debit is called by store.orders.approve (Refuse a Request), with the order’s id as ref. approve is a transition declared concurrency: retry: when the order changes under it, it is run again, and calls debit again. once cannot cover that. It recognises a call delivered again, and an attempt made because a write was lost makes a new call; the runtime refuses a call from such a transition to an operation that declares once (Two Writers at Once).

So debit is safe to repeat by what it is. “The payment for order X” can be asked for by a second attempt, a redelivery, a second click or another operation altogether, a minute or a month later, and is taken once. credit has no such name to go by — goodwill money granted twice on purpose is two credits, and its ref says what it was for, not which one it is — and nothing that retries calls it: it is reached by an event and by a staff route. There once is right.

The key has a cost: the wallet remembers every ref it has taken money for, and an object that only grows is read and written whole on every debit. What is remembered, and for how long, is the wallet’s decision, not the runtime’s. A ref has to stay while a repeat of its debit can still arrive — here, while the order can still be approved. A wallet that cannot keep them all gets an operation that deletes the key and a receiver that binds it to the events after which nobody asks again, as Reservations removes a hold’s key when the hold is settled or released. A key removed too early makes the late repeat a second debit.

TRAILERS is where an operation says what happened in this write. The wallet stores what it has; what moved, of what kind and for what, is not part of the wallet — it is a fact about one write, and it travels with the event.

§The ledger

The wallet announces every write that moved money, and the payload is the ledger line:

// components/billing.wallets/events/moved.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'

interface Movement {
  amount: number
  type: string
  ref: string
}

export function condition (event: Event<Entity, Partial<Movement>>) {
  return event.trailers?.amount !== undefined
}

export function payload (event: Event<Entity, Movement>) {
  const { id, balance, VERSION } = event.state
  const { amount, type, ref } = event.trailers

  return {
    owner: id,
    amount,                      // signed: what the balance moved by
    type,
    ref,
    balance,                     // what the move left behind
    version: VERSION,
    key: `${id}:${VERSION}`      // names this one write of this one wallet
  }
}
# components/billing.ledger/manifest.toa.yaml
name: ledger
namespace: billing

entity:
  properties:
    owner: { type: string }
    amount: { type: integer }
    type: { type: string, enum: [topup, refund, goodwill, payment] }
    ref: { type: string }
    balance: { type: integer, minimum: 0 }
    version: { type: integer }
    key: { type: string }
  required: [owner, amount, type, ref, balance, version, key]

operations:
  record:
    query: true

receivers:
  billing.wallets.moved: record

exposition:
  /:owner:
    auth:id: owner
    io:output: [id, amount, type, ref, balance, CREATED]
    GET:
      endpoint: enumerate
      query:
        sort: version:desc
// components/billing.ledger/receivers/billing.wallets.moved.ts
export function request (line: { key: string }) {
  return {
    query: { criteria: `key=='${line.key}'` },
    entity: line
  }
}
// components/billing.ledger/operations/record.ts
import type { Entity } from '../types/index.d.ts'

export async function effect (input: null, entry: Entity) {
  return entry
}
# components/billing.ledger/migrations/0001-indexes.yaml
- index:
    name: unique_key
    keys: { key: asc }
    unique: true
- index:
    name: index_owner
    keys: { owner: asc, version: desc }

auth:id lets a customer list the lines of their own wallet and nobody else’s.

The receiver’s request carries entity: the effect is given the line stored under that key, or, if there is none, the line is created from what was handed over. record has nothing left to do. A second arrival of the same event finds the line and creates nothing, and the unique index holds that even for two arrivals at the same instant. A ledger line is never deleted, so the index carries no partial filter: a key stays taken for good.

billing.wallets.credit / debit    one write: the balance, and the event with the line
        ~ billing.wallets.moved   key = <wallet id>:<VERSION>
        v
billing.ledger.record             creates the line under the key, or finds it

§Try it

$ npm run restart
$ npx toa call billing.wallets.credit 
    "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 5000, type: goodwill, ref: welcome } }"
$ npx toa call billing.wallets.debit 
    "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 1800, ref: order-1 } }"
$ npx toa call billing.ledger.enumerate 
    "{ query: { criteria: 'owner==8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f', sort: ['version:desc'], limit: 10 }, output: [amount, type, balance] }"
[
  { amount: -1800, type: 'payment', balance: 3200 },
  { amount: 5000, type: 'goodwill', balance: 5000 }
]

A debit of more than is there answers the error INSUFFICIENT_FUNDS, changes nothing, and leaves no line. The invariant is worth a scenario of its own in the project’s tests: after any sequence of credits and debits, the amounts of a wallet’s lines add up to its balance.

§Why the line is an event of the wallet

The event is recorded in the same write as the balance. Either both exist or neither does; there is no moment in which a process can stop and leave one without the other (Transactional Outbox). The ledger may be down for an hour — the lines are written when it is back, none missing.

When two records must never disagree, make one of them the stored fact, and derive the other from the event of the write that changed it — under a key that names that write.

id:VERSION is such a key. Every stored change of an object raises its VERSION by one, so the pair names one write, and a line under it can exist once.

The balance is the stored fact, and not the lines, because the balance is what has to be checked and changed in one step. The lines are for reading.

§What can still go wrong

The same credit twice, soon. once answers the repeat with the first reply; one write, one line.

The same payment reported again after days. once remembers a call for an hour by default, and a repeat from outside is not the same call anyway. Such repeats are stopped before the wallet, by a record of what was paid: Receiving Webhooks. Do not point anything that can repeat for days straight at credit.

A client retries a credit made by staff. A staff route that grants goodwill money is called with an idempotency-key header; the retry under the same key is the same call, and once covers it.

The moved event is delivered twice. The second arrival finds the line under its key.

Lines are written out of order. Events may overtake each other, so the line of write 7 can be stored before the line of write 6. Each line carries the wallet’s version; order by it, not by when the line was created.

A line is wrong. It stays. A correction is another movement — a refund, a goodwill credit — and so another line. Expose no operation that changes or deletes a line.

The order service runs approve twice. It calls debit twice with the order’s id as ref. The second finds the key in paid, moves nothing and leaves no line.

Two debits use one ref for different things. The second is taken for a repeat and takes nothing. A ref names one payment: use an id that is its own, the order’s, not the customer’s.

A debit that cannot be refused. debit is asked before something is handed over, so it may say no. A charge that arrives after the work is done has nothing to refuse; what to do then — take what is there and record the shortfall — is a business decision, discussed in Reservations and Saying No.

once needs a storage that supports it. credit declares it, and a component that declares once starts only on MongoDB running as a replica set or a sharded cluster (Handle Repeated Calls).

§Rules of thumb

  • Store the balance; never compute it from the lines when deciding.
  • One wallet per owner, associated, with a blank of zero.
  • Every operation that moves money is a transition with concurrency: retry, and safe to repeat: keyed by what it is for where it has such a name, once where it has none.
  • A once operation is never called from a transition that retries.
  • Amounts are integers in the smallest unit.
  • Say what moved in TRAILERS; a write with no trailer has no line.
  • The line is the payload of the wallet’s event, keyed id:VERSION, created at most once by a unique index.
  • Every movement names what it was for in ref.
  • Nothing rewrites a line; a correction is another movement.
  • Keep far duplicates away from the wallet with a record of their own.
Search
Search the documentation