Velocity Controls

Reference for velocity controls and limits within a Twisp ledger, including how a limit is checked and when it is not.

The Basics

Velocity Controls enforce limits on account balances as transactions post. Use them to stop an account from going negative, to cap spending per day, or to cap any other balance you can describe with entries.

A velocity control is checked inside the posting's own database transaction. A posting that violates a REJECT control is not written.

Components of a Velocity Control

A Velocity Control is made up of two main components:

  • Velocity Control: Defines the enforcement action taken when one of its limits is exceeded, and an optional condition that decides whether to check a posting at all.
  • Velocity Limit: Defines a running balance, the entries that count toward it, and the bound that balance must stay within.

Once a Velocity Control is defined, you attach it to accounts or account sets. Each posting to an attached account is then checked against the control's limits. If a limit is exceeded, the control's enforcement action decides what happens to the transaction.

Velocity Control

There are 3 key components that make up a Velocity Control:

  1. Enforcement: What happens when a limit is exceeded. There are three actions:
    • REJECT: the request fails with error code BALANCE_VELOCITY_EXCEEDED and nothing is posted.
    • WARN: the transaction posts, and the violation is returned in exceptions on the transaction.
    • VOID: the transaction posts and is then voided. The violation is returned in exceptions.
  2. Velocity Limits: One or more limits checked by this control.
  3. Condition: An optional CEL expression that returns a boolean. When it is false for a posting, the control does not check that posting. It is evaluated once for each of the transaction's entries on the account that count toward a limit, with context.vars.entry set to that entry, and the posting is checked when it is true for any of them, whatever their order. Base it on facts about the whole transaction, such as context.vars.transaction.tran_code_id or transaction metadata. To choose which entries count toward a limit, use the limit's condition instead.

Velocity Limits

A Velocity Limit keeps one running balance for each attached account, built only from the entries that pass its condition. There are 5 key components of a Velocity Limit:

  1. Window: Every limit is kept separately per journal, account, and currency. Each window expression adds another dimension, such as the effective date for a daily limit. window: [] keeps a single balance for the whole life of the account in that journal and currency.
  2. Limit: The bound to enforce. The only kind is an available balance limit, which has:
    • layer: SETTLED, PENDING, or ENCUMBRANCE. The balance checked is the available balance at that layer: settled, settled plus pending, or settled plus pending plus encumbrance.
    • amount: the upper bound, as a decimal.
    • normalBalanceType: DEBIT or CREDIT. The direction the balance is read in: DEBIT reads debits minus credits, CREDIT reads credits minus debits.
    • start and end: optional timestamps that bound when the limit applies. start defaults to the time the control is attached; end defaults to no end. If balance lists more than one limit, the first one whose range contains the transaction's timestamp is used.
  3. Condition: An optional CEL expression that returns a boolean. It is evaluated for each entry, and only entries for which it is true count toward the limit. For example, context.vars.entry.direction == DEBIT counts only debits.
  4. Currency: The limit applies only to entries in this currency.
  5. Params: Parameters whose values are supplied when the control is attached. This lets one control enforce different amounts on different accounts.

How a Limit Is Checked

For each posting to an attached account, Twisp adds the transaction's entries to the limit's balance and reads the available balance at the limit's layer, in the limit's normalBalanceType direction. The posting violates the limit when that balance is greater than amount. A balance equal to amount passes.

So amount is an upper bound on the balance after the posting, read in the normalBalanceType direction:

GoalLimit
A credit-normal account may not go below zeronormalBalanceType: "DEBIT", amount: "decimal('0')", no condition, window: []
Debits per day may not exceed 100.00normalBalanceType: "DEBIT", amount: "decimal('100.00')", condition context.vars.entry.direction == DEBIT, a window on the effective date

In the error message velocity 0 USD exceeded, balance is 50.00 USD, the balance is the value the posting would have produced, read in the limit's direction. For a no-overdraft limit it is the size of the overdraft, not the account's balance.

See Controlling Balances with Velocity Controls for both recipes as complete GraphQL.

When a Limit Is Not Checked

A posting is not checked against a limit when:

  • The posting is in a different journal from the one the control was attached on.
  • The entries are in a different currency from the limit's currency.
  • The control's condition is false.
  • Every entry the transaction writes to the account is at a higher layer than the limit's layer. For example, a hold posted only at ENCUMBRANCE is not checked against a PENDING limit. Use an ENCUMBRANCE limit to cover every layer.
  • The posting is a void (voidTransaction). Voids are never checked, and a void's entries still count toward the limit's balance.
  • The control was attached to an account that already had entries, and the replay of that history has not finished (see below).

Several Transactions in One Request

The gRPC PostTransactions API posts a list of transactions and voids in one request, all or nothing. Velocity controls check it exactly as they would check the same operations posted one at a time, in the same order:

  • Each transaction is checked against the balance it leaves: the balance after it and every operation before it in the request. A credit later in the request does not cover an earlier debit, so put money in before the money that goes out.
  • A control's condition sees each transaction's own entries, and each transaction's overrideVelocityEnforcement applies to that transaction only.
  • A WARN control records an exception on each transaction that exceeds a limit.
  • Voids are not checked, and their entries count toward the balances of the transactions after them.

If any transaction would be rejected, the request fails with BALANCE_VELOCITY_EXCEEDED and nothing is posted. One action differs: a VOID control rejects the request instead of posting and voiding the transaction. A transaction whose tran code starts a workflow is the exception; it is posted and voided as it would be alone.

Attaching Velocity Controls

Attaching a Velocity Control to an account or account set starts enforcing its limits on postings to that account.

There are four components to an attachment:

  1. Velocity Control Id: The control to attach.
  2. Account or Set Id: The account or account set to attach the control to.
  3. Journal Id: The journal to enforce on. Defaults to the default journal. The control checks only postings in this journal; attach once per journal you post to.
  4. Params: A JSON object with a value for every parameter defined on the control's limits.

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

To know when enforcement has begun, read ready on the attached control. There is one node per limit, and each is true once postings to the account are checked against that limit. It can read false for a few seconds after an account's first postings even when the control was attached before them:

query {
  attachedControls(accountId: "<account id>", first: 10) {
    nodes { velocityControlId ready limits { velocityLimitId } }
  }
}

If the account has no entries yet, enforcement begins with its first posting. To have a control enforced from the start, attach it in the same request that creates the account.

When defining parameters on Velocity Limits use unique parameter names to avoid collisions between parameters.

Writing Limit Expressions

The fields of a limit are CEL expressions, and their types are strict:

  • amount must be a decimal: "decimal('100.00')", not "100.00" or "'100.00'".
  • layer and normalBalanceType are the bare enum names: "PENDING", "DEBIT".
  • Records on context.vars (account, transaction, entry) use snake_case field names: context.vars.entry.entry_type, context.vars.transaction.tran_code_id. See the entity field reference.
  • Compare UUID fields to uuid('…') values. A UUID compared to a plain string is never equal.

createVelocityLimit, createVelocityControl, and their updates check expressions and reject a mistake with BAD_REQUEST, naming the field. For example:

  • limit.balance[0].amount: `100.00` must resolve to a decimal, such as "decimal('100.00')"
  • condition: … context.vars.entry has no field "entryType"; did you mean "entry_type"?
  • condition: … context.vars.transaction.tran_code_id is a UUID, which never equals a string; compare it to uuid('…')

A limit expression that reads a parameter with neither a default nor an example is checked only for its type at creation; the rest is checked when the control is attached with that parameter's value. So are checks that depend on when the control is attached, such as overlapping start and end windows.

Changing Controls and Limits

While a control is attached to any account:

  • updateVelocityControl can change the control's name, description, enforcement, and condition.
  • updateVelocityLimit and deleteVelocityLimit fail with velocity limit in use.
  • deleteVelocityControl, addLimitToControl, removeLimitFromControl, and removeAllLimitsFromControl fail with velocity control in use.

To change a limit, detach the control from every account, update the limit, and attach the control again. Attaching again replays each account's history, as described above.

Overriding Velocity Control Enforcement

To change the action for a single posting, set properties.overrideVelocityEnforcement on the postTransaction input:

mutation ForcePost {
  postTransaction(input: {
    transactionId: "3f3c1d5e-6a07-4a8e-9b0a-7f1f7d2a9c11"
    tranCode: "WITHDRAW"
    params: { wallet: "a11ce000-0000-4000-8000-000000000001", amount: "50.00" }
    properties: { overrideVelocityEnforcement: { action: WARN } }
  }) {
    transactionId
    exceptions { type message }
  }
}

The override applies to controls whose action is VOID or REJECT. Setting it to WARN force-posts the transaction: it posts, and any violations are returned in exceptions.

Several Transactions in One Request

The gRPC PostTransactions API posts a list of transactions and voids in one request, all or nothing. Velocity controls check it exactly as they would check the same operations posted one at a time, in the same order:

  • Each transaction is checked against the balance it leaves: the balance after it and every operation before it in the request. A credit later in the request does not cover an earlier debit, so put money in before the money that goes out.
  • A control's condition is evaluated for each transaction on its own, and each transaction's overrideVelocityEnforcement applies to that transaction only.
  • A WARN control records an exception on each transaction that exceeds a limit.
  • Voids are not checked, and their entries count toward the balances of the transactions after them.

If any transaction would be rejected, the request fails with BALANCE_VELOCITY_EXCEEDED and nothing is posted. One action differs: a VOID control rejects the request instead of posting and voiding the transaction. A transaction whose tran code starts a workflow is the exception; it is posted and voided as it would be alone.

Velocity Control Operations

Use GraphQL queries and mutations to read, create, update, delete, and attach Velocity Controls and limits:

Further Reading

To create and attach a no-overdraft control and a daily spending limit step by step, see Controlling Balances with Velocity Controls.

Previous
Transactions