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
velocityquery. - 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 atENCUMBRANCE, uselayer: "ENCUMBRANCE".amountmust be a decimal:"decimal('0')".layerandnormalBalanceTypeare bare enum names.- The control's
conditionskips the check forDEPOSITtransactions. 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. Comparetran_code_idtouuid('…'); 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
windowentry starts a new total each effective date. - The limit's
conditionis 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:
| Posting | Result |
|---|---|
WITHDRAW 60.00 | Posts. |
WITHDRAW 50.00 | Rejected: velocity 100.00 USD exceeded, balance is 110.00 USD. |
WITHDRAW 40.00 | Posts. The day's total is exactly 100.00. |
DEPOSIT 100.00, then WITHDRAW 0.01 | The 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.