Ledger Invariants: Create and Attach Velocity Controls to Accounts

Build a no-overdraft control and a daily spending limit for wallet accounts, with GraphQL you can run as written.

Velocity controls reject, warn on, or void postings that would push an account's balance past a limit. In this tutorial you will build two common controls for customer wallets: one that keeps a wallet from going negative, and one that caps spending per day.

  • Create a no-overdraft limit and control, and attach it to a wallet.
  • Create a daily spending limit and attach it alongside the first control.
  • Read a limit's balance with the velocity query.
  • Force-post a transaction past a control.

Every example on this page was run against a Twisp ledger as written. For how limits are evaluated, see Velocity Controls.


Set up accounts and tran codes

The examples use a settlement account and two tran codes: DEPOSIT moves money into a wallet, and WITHDRAW moves it out. Wallets are credit-normal accounts, so a positive balance is money the customer holds.

mutation Setup {
  settlement: createAccount(input: {
    accountId: "5e771e00-0000-4000-8000-000000000001"
    code: "SETTLEMENT"
    name: "Settlement"
    normalBalanceType: DEBIT
  }) { accountId }

  deposit: createTranCode(input: {
    tranCodeId: "7c000000-0000-4000-8000-000000000001"
    code: "DEPOSIT"
    description: "Move funds from settlement into a wallet."
    params: [
      { name: "wallet", type: UUID }
      { name: "amount", type: DECIMAL }
    ]
    transaction: { description: "'Deposit'" }
    entries: [
      { accountId: "uuid('5e771e00-0000-4000-8000-000000000001')", units: "params.amount", currency: "'USD'", entryType: "'DEPOSIT_DR'", direction: "DEBIT", layer: "SETTLED" }
      { accountId: "params.wallet", units: "params.amount", currency: "'USD'", entryType: "'DEPOSIT_CR'", direction: "CREDIT", layer: "SETTLED" }
    ]
  }) { tranCodeId }

  withdraw: createTranCode(input: {
    tranCodeId: "7c000000-0000-4000-8000-000000000002"
    code: "WITHDRAW"
    description: "Move funds from a wallet to settlement."
    params: [
      { name: "wallet", type: UUID }
      { name: "amount", type: DECIMAL }
    ]
    transaction: { description: "'Withdrawal'" }
    entries: [
      { accountId: "params.wallet", units: "params.amount", currency: "'USD'", entryType: "'WITHDRAW_DR'", direction: "DEBIT", layer: "SETTLED" }
      { accountId: "uuid('5e771e00-0000-4000-8000-000000000001')", units: "params.amount", currency: "'USD'", entryType: "'WITHDRAW_CR'", direction: "CREDIT", layer: "SETTLED" }
    ]
  }) { tranCodeId }
}

Neither tran code sets transaction.effective, so every transaction is effective on the day it posts. Callers can't choose the date, which matters for the daily limit below.

Prevent overdrafts

Create the limit and control

A limit's amount is an upper bound on the balance after a posting, read in the limit's normalBalanceType direction. A wallet that may not go negative is one whose debits minus credits may not exceed zero:

mutation NoOverdraft {
  createVelocityLimit(input: {
    velocityLimitId: "11000000-0000-4000-8000-000000000001"
    name: "No overdraft"
    description: "Available balance (settled + pending) may not go below zero."
    window: []
    currency: "USD"
    limit: {
      balance: [{ layer: "PENDING", amount: "decimal('0')", normalBalanceType: "DEBIT" }]
    }
  }) { velocityLimitId }

  createVelocityControl(input: {
    velocityControlId: "c1000000-0000-4000-8000-000000000001"
    name: "No overdraft"
    description: "Reject postings that leave a wallet's available balance below zero. Deposits are exempt."
    enforcement: { action: REJECT }
    condition: "context.vars.transaction.tran_code_id != uuid('7c000000-0000-4000-8000-000000000001')"
    velocityLimitIds: ["11000000-0000-4000-8000-000000000001"]
  }) { velocityControlId }
}

How this is built:

  • window: [] keeps one running balance per wallet for its whole life.
  • The limit has no condition, so every entry counts and the limit's balance is the wallet's real balance.
  • layer: "PENDING" checks settled plus pending entries, so pending holds count too. If you post holds at ENCUMBRANCE, use layer: "ENCUMBRANCE".
  • amount must be a decimal: "decimal('0')". layer and normalBalanceType are bare enum names.
  • The control's condition skips the check for DEPOSIT transactions. Without it, a wallet that is already negative (for example after a force-post) would reject any deposit too small to bring it back to zero. Compare tran_code_id to uuid('…'); a UUID compared to a plain string is never equal.

Open a wallet with the control attached

Attach the control in the same request that creates the account. An account with no entries is enforced from its first posting:

mutation OpenWallet {
  createAccount(input: {
    accountId: "a11ce000-0000-4000-8000-000000000001"
    code: "WALLET-ALICE"
    name: "Alice"
    normalBalanceType: CREDIT
  }) { accountId }

  attachVelocityControl(
    velocityControlId: "c1000000-0000-4000-8000-000000000001"
    accountId: "a11ce000-0000-4000-8000-000000000001"
  ) { velocityControlId }
}

attachVelocityControl also takes an optional journalId. The control checks only postings in the journal it was attached on, which defaults to the default journal.

Try it

Deposit 100.00 into the wallet, then try to withdraw 150.00:

mutation Withdraw {
  postTransaction(input: {
    transactionId: "8d1c2f7a-3b1e-4f55-9a4e-2c6b1f0e7a01"
    tranCode: "WITHDRAW"
    params: { wallet: "a11ce000-0000-4000-8000-000000000001", amount: "150.00" }
  }) { transactionId }
}

The withdrawal is rejected and nothing is posted:

{
  "errors": [
    {
      "message": "input: postTransaction velocity 0 USD exceeded, balance is 50.00 USD",
      "path": ["postTransaction"],
      "extensions": {
        "code": "BALANCE_VELOCITY_EXCEEDED",
        "exception": {
          "type": "VELOCITY_CONTROL",
          "errorMessage": "velocity 0 USD exceeded, balance is 50.00 USD",
          "detail": {
            "accountId": "a11ce000-0000-4000-8000-000000000001",
            "layer": "PENDING",
            "limit": "0",
            "velocityControlId": "c1000000-0000-4000-8000-000000000001",
            "velocityLimitId": "11000000-0000-4000-8000-000000000001",
            "violatingBalance": "50.00 USD"
          }
        }
      }
    }
  ],
  "data": null
}

The balance in the message is the value the posting would have produced, read in the limit's direction. Here it is the size of the overdraft.

A withdrawal of exactly 100.00 posts, because a balance equal to amount passes. A further withdrawal of 0.01 is then rejected with balance is 0.01 USD.

Limit daily spending

Create the limit and control

This limit counts only debits to the wallet and keeps a separate total for each effective date:

mutation DailySpend {
  createVelocityLimit(input: {
    velocityLimitId: "11000000-0000-4000-8000-000000000002"
    name: "Daily spend 100 USD"
    description: "Debits per effective date may not exceed 100.00 USD."
    window: [{ alias: "date", value: "context.vars.transaction.effective" }]
    condition: "context.vars.entry.direction == DEBIT"
    currency: "USD"
    limit: {
      balance: [{ layer: "PENDING", amount: "decimal('100.00')", normalBalanceType: "DEBIT" }]
    }
  }) { velocityLimitId }

  createVelocityControl(input: {
    velocityControlId: "c1000000-0000-4000-8000-000000000002"
    name: "Daily spend limit"
    description: "Reject debits once a wallet has spent 100.00 USD on the effective date."
    enforcement: { action: REJECT }
    velocityLimitIds: ["11000000-0000-4000-8000-000000000002"]
  }) { velocityControlId }
}
  • The window entry starts a new total each effective date.
  • The limit's condition is evaluated for each entry, so only debits count. Deposits don't restore the day's allowance.
  • Voids are not checked, and a voided withdrawal gives its amount back to the day's total.

Attach both controls to a new wallet

An account can have several controls attached. Each is checked on every posting:

mutation OpenWalletWithDailyLimit {
  createAccount(input: {
    accountId: "b0b00000-0000-4000-8000-000000000001"
    code: "WALLET-BOB"
    name: "Bob"
    normalBalanceType: CREDIT
  }) { accountId }

  noOverdraft: attachVelocityControl(
    velocityControlId: "c1000000-0000-4000-8000-000000000001"
    accountId: "b0b00000-0000-4000-8000-000000000001"
  ) { velocityControlId }

  dailySpend: attachVelocityControl(
    velocityControlId: "c1000000-0000-4000-8000-000000000002"
    accountId: "b0b00000-0000-4000-8000-000000000001"
  ) { velocityControlId }
}

Try it

After depositing 500.00 into Bob's wallet:

PostingResult
WITHDRAW 60.00Posts.
WITHDRAW 50.00Rejected: velocity 100.00 USD exceeded, balance is 110.00 USD.
WITHDRAW 40.00Posts. The day's total is exactly 100.00.
DEPOSIT 100.00, then WITHDRAW 0.01The deposit posts; the withdrawal is rejected with balance is 100.01 USD.

Read the day's total with the velocity query. The window keys match the limit's window aliases:

query DailyTotal {
  velocity(
    accountId: "b0b00000-0000-4000-8000-000000000001"
    velocityLimitId: "11000000-0000-4000-8000-000000000002"
    window: { date: "2026-10-01" }
  ) { spent remaining }
}
{ "data": { "velocity": [{ "spent": "100.00", "remaining": "0.00" }] } }

Holds that later settle

If spending first posts a pending hold and later settles it, the settlement usually releases the hold with a credit and posts the settled debit. A debits-only condition counts that spending twice: a 30.00 hold followed by its settlement shows spent of 60.00.

To count it once, count entries in both directions and leave out money coming in. With the entry types from this tutorial:

condition: "!context.vars.entry.entry_type.startsWith('DEPOSIT_')"

With this condition, the same hold and settlement show spent of 30.00. Entry fields in CEL use snake_case: entry_type, not entryType.

Attach to an account that already has entries

Limits count an account's existing history. When you attach a control to an account that already has entries, Twisp replays them into the limit's balance. Enforcement on that account begins once the replay completes, which is not immediate. Postings made before then are not checked, but they are counted once the replay finishes.

Attach controls when you create accounts whenever you can. If you attach to an existing account, don't rely on the control until the replay has finished.

Force-post past a control

To post a single transaction that a REJECT or VOID control would stop, set properties.overrideVelocityEnforcement:

mutation ForcePost {
  postTransaction(input: {
    transactionId: "8d1c2f7a-3b1e-4f55-9a4e-2c6b1f0e7a02"
    tranCode: "WITHDRAW"
    params: { wallet: "a11ce000-0000-4000-8000-000000000001", amount: "50.00" }
    properties: { overrideVelocityEnforcement: { action: WARN } }
  }) {
    transactionId
    exceptions { type message }
  }
}

The transaction posts and returns the violation in exceptions:

{
  "data": {
    "postTransaction": {
      "transactionId": "8d1c2f7a-3b1e-4f55-9a4e-2c6b1f0e7a02",
      "exceptions": [{ "type": "VELOCITY_CONTROL", "message": "velocity 0 USD exceeded, balance is 50.00 USD" }]
    }
  }
}

Change a limit

A limit can't be changed while its control is attached to any account. updateVelocityLimit fails with velocity limit in use. Detach the control from each account with detachVelocityControl, update the limit, and attach the control again. Attaching again replays each account's history before enforcement resumes.

You can change a control's name, description, enforcement, and condition with updateVelocityControl while it is attached.

Previous
Balance Calculations