Modeling Banking on Twisp

Design and Implement bank-like core accounting on Twisp.

Context

Organizations that offer financial products as part of their core product offering have a wide variety of service providers to bring products to market. There are vertical banking integrations such as Unit, which provide turn key treasury, lending and card operations. Other companies are more specialized, card processors like Marqeta and Lithic and ACH and payments companies like Sila and Stripe. As a company building on top of these service providers you're often going through a "Crawl, Walk, Run" maturation cycle:

The goal as a fintech is to prove your product and get to the "Run" stage as fast as possible. One of the first systems you'll need to build and operate well is a core account system. Twisp is a core accounting system designed to provide a system of record for organizations that build financial products and services.

In this document we'll dive into how to start modeling deposit accounts, credit accounts and their interaction with various payment instruments, which you'll find applicable to any stage of development.

Scope

In this document we'll cover:

  1. Deposit and Credit accounts
  2. Card transactions
  3. ACH

We will design a number of "system level" accounts for operational and double entry accounting purposes. And we'll build out sample chart of accounts for a business neobanking vertical that we can iterate on toward your use case.

Chart of Accounts

The chart of accounts in your fintech system is the building block for how you want balances to "roll up" for both end users and for your platform. It is helpful to think of these charts as separate ones that interact with each other when funds are spent:

  1. End User: Track end user activity and control how balances "roll up" for end users of our system.
  2. Platform: Track settlements, revenue and operational concerns of the platform.

End User

We're going to cover two basic kinds of accounts:

  • Deposit Accounts: DDA accounts are stores of value for banking customers. Debit instruments are hooked up to these accounts and these accounts are considered liabilities to the platform, because the platform owes the deposits to the account holders.
  • Credit Accounts: Credit accounts are accounts with a line of credit (as we'll see later on, literally a line on an account) coupled with a payable account. Customers will spend money and then they owe the account issuer funds back by a certain date, and the payable account accrues interest.

Twisp models both accounts similarly:

  • An account set to represent the total balance of the account
  • A default account to represent debits/credits incurred by the account holder

The difference between the two will be the Balance Normality of the accounts involved, and the credit accounts will have an extra account to hold the credit line.

Deposit Accounts

The chart of accounts for end users will model a Customer -> Account -> Card hiearchical relationship. This hierarchical relationship we'll model via Account Sets. Each individual entity we'll create via a two objects as a building block for creating the chart of accounts:

  1. An Account Set to encapsulate the total balance of the entity in question.
  2. A default Account as a member of the above account set for writing entries to the entity.

This building block can be encapsulated in via a GraphQL mutation:

001_CreateAccount

mutation CreateEntityAccount(
  $accountSetId: UUID!
  $accountId: UUID!
  $code: String!
  $description: String
  $metadata: JSON
  $journalId: UUID! = "00000000-0000-0000-0000-000000000000"
  $accountSetName: String! = "Entity Account Set"
  $accountName: String! = "Entity Default Account"
) {
  createAccountSet(
    input: {
      accountSetId: $accountSetId
      name: $accountSetName
      description: $description
      metadata: $metadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  createAccount(
    input: {
      accountId: $accountId
      name: $accountName
      description: $description
      code: $code
      accountSetIds: [$accountSetId]
      metadata: $metadata
    }
  ) {
    accountId
  }
}

Once we have this building block, we can now model the chart of accounts creating and adding to the appropriate account set.

Consider the use case of onboarding a customer, followed by creating a deposit account and issuing a debit card; After onboarding we'd expect to have the following end user chart of accounts:

The coordination of creating an account in Twisp can occur in many ways. For example, it may be in response to webhooks from a BaaS provider:

Webhook ReceivedActions in Twisp
customer.createdCreate customer account
account.createdCreate deposit account, add to customer account
card.createdCreate card account, add to corresponding deposit account.

Another might be creating Twisp entities via a state machine process, such as Temporal or Step Functions, coordinating activity with a number of vendors:

002_OnboardingExample

# OnboardCustomerDepositAccountCard does exactly that:
# - Creates  Account Set and Default Account for Customer
# - Creates Account Set and Default Account for Deposit Account
# - Adds Deposit Account to Customer
# - Creates Account Set and Default Account for Card
# - Adds Card to Deposit Account
# This includes adding Unit metadata json. This can be
# broken up into multiple mutations in response to a webhook
# or used as-is in some kind of long running coordinated process
# to onboard a customer.
mutation OnboardCustomerDepositAccountCard(
  $customerAccountSetId: UUID!
  $customerAccountId: UUID!
  $customerAccountCode: String!
  $depositAccountSetId: UUID!
  $depositAccountId: UUID!
  $depositAccountCode: String!
  $cardAccountSetId: UUID!
  $cardAccountId: UUID!
  $cardAccountCode: String!
  $description: String
  $customerMetadata: JSON
  $depositMetadata: JSON
  $cardMetadata: JSON
  $journalId: UUID! = "00000000-0000-0000-0000-000000000000"
  $customerAccountSetName: String! = "Customer Account Set"
  $customerAccountName: String! = "Customer Default Account"
  $depositAccountSetName: String! = "Deposit Account Set"
  $depositAccountName: String! = "Deposit Default Account"
  $cardAccountSetName: String! = "Card Account Set"
  $cardAccountName: String! = "Card Default Account"
) {
  # Onboard Customer
  customerSet: createAccountSet(
    input: {
      accountSetId: $customerAccountSetId
      name: $customerAccountSetName
      description: $description
      metadata: $customerMetadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  customerDefault: createAccount(
    input: {
      accountId: $customerAccountId
      name: $customerAccountName
      description: $description
      code: $customerAccountCode
      accountSetIds: [$customerAccountSetId]
      metadata: $customerMetadata
    }
  ) {
    accountId
  }

  # Onboard Deposit Account
  depositSet: createAccountSet(
    input: {
      accountSetId: $depositAccountSetId
      name: $depositAccountSetName
      description: $description
      metadata: $depositMetadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  depositDefault: createAccount(
    input: {
      accountId: $depositAccountId
      name: $depositAccountName
      description: $description
      code: $depositAccountCode
      metadata: $depositMetadata
    }
  ) {
    accountId
  }
  depositToCustomer: addToAccountSet(
    id: $customerAccountSetId
    member: { memberType: ACCOUNT_SET, memberId: $depositAccountSetId }
  ) {
    accountSetId
  }
  defaultDepositToSet: addToAccountSet(
    id: $depositAccountSetId
    member: { memberType: ACCOUNT, memberId: $depositAccountId }
  ) {
    accountSetId
  }

  # Onboard Card
  cardSet: createAccountSet(
    input: {
      accountSetId: $cardAccountSetId
      name: $cardAccountSetName
      description: $description
      metadata: $cardMetadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  cardDefault: createAccount(
    input: {
      accountId: $cardAccountId
      name: $cardAccountName
      description: $description
      code: $cardAccountCode
      metadata: $cardMetadata
    }
  ) {
    accountId
  }
  cardToDeposit: addToAccountSet(
    id: $depositAccountSetId
    member: { memberType: ACCOUNT_SET, memberId: $cardAccountSetId }
  ) {
    accountSetId
  }
  defaultCardToSet: addToAccountSet(
    id: $cardAccountSetId
    member: { memberType: ACCOUNT, memberId: $cardAccountId }
  ) {
    accountSetId
  }
}

This can be broken down into reusable pieces in case you want to add multiple deposit accounts or cards to a particular customer:

030_CreateCustomer

mutation CreateCustomer(
  $customerAccountSetId: UUID!
  $customerAccountId: UUID!
  $customerAccountCode: String!
  $journalId: UUID! = "00000000-0000-0000-0000-000000000000"
  $customerAccountSetName: String! = "Customer Account Set"
  $customerAccountName: String! = "Customer Default Account"
  $customerMetadata: JSON
  $description: String = "Create a customer."
) {
  customerSet: createAccountSet(
    input: {
      accountSetId: $customerAccountSetId
      name: $customerAccountSetName
      description: $description
      metadata: $customerMetadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  customerDefault: createAccount(
    input: {
      accountId: $customerAccountId
      name: $customerAccountName
      description: $description
      code: $customerAccountCode
      accountSetIds: [$customerAccountSetId]
      metadata: $customerMetadata
    }
  ) {
    accountId
  }
}

031_CreateDepositAccount

mutation CreateDepositAccount(
  $customerAccountSetId: UUID!
  $depositAccountSetId: UUID!
  $depositAccountId: UUID!
  $depositAccountCode: String!
  $journalId: UUID! = "00000000-0000-0000-0000-000000000000"
  $depositAccountSetName: String! = "Deposit Account Set"
  $depositAccountName: String! = "Deposit Default Account"
  $depositMetadata: JSON
  $description: String = "create a deposit account."
) {
  depositSet: createAccountSet(
    input: {
      accountSetId: $depositAccountSetId
      name: $depositAccountSetName
      description: $description
      metadata: $depositMetadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  depositDefault: createAccount(
    input: {
      accountId: $depositAccountId
      name: $depositAccountName
      description: $description
      code: $depositAccountCode
      metadata: $depositMetadata
    }
  ) {
    accountId
  }
  depositToCustomer: addToAccountSet(
    id: $customerAccountSetId
    member: { memberType: ACCOUNT_SET, memberId: $depositAccountSetId }
  ) {
    accountSetId
  }
  defaultDepositToSet: addToAccountSet(
    id: $depositAccountSetId
    member: { memberType: ACCOUNT, memberId: $depositAccountId }
  ) {
    accountSetId
  }
}

032_CreateCard

mutation CreateCard(
  $depositAccountSetId: UUID!
  $cardAccountSetId: UUID!
  $cardAccountId: UUID!
  $cardAccountCode: String!
  $cardAccountSetName: String! = "Card Account Set"
  $cardAccountName: String! = "Card Default Account"
  $journalId: UUID! = "00000000-0000-0000-0000-000000000000"
  $cardMetadata: JSON
  $description: String = "Create a card."
) {
  cardSet: createAccountSet(
    input: {
      accountSetId: $cardAccountSetId
      name: $cardAccountSetName
      description: $description
      metadata: $cardMetadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  cardDefault: createAccount(
    input: {
      accountId: $cardAccountId
      name: $cardAccountName
      description: $description
      code: $cardAccountCode
      metadata: $cardMetadata
    }
  ) {
    accountId
  }
  cardToDeposit: addToAccountSet(
    id: $depositAccountSetId
    member: { memberType: ACCOUNT_SET, memberId: $cardAccountSetId }
  ) {
    accountSetId
  }
  defaultCardToSet: addToAccountSet(
    id: $cardAccountSetId
    member: { memberType: ACCOUNT, memberId: $cardAccountId }
  ) {
    accountSetId
  }
}

Regardless of the mechanics of how/when new accounts are added, we suggest association of your own internal and third party identifiers on Twisp entities so that you can easily look up items in the future.

Picking identifiers for Twisp and ensuring there is enough data in Twisp to look up by Unit identifiers, or your own system internal identifiers is paramount for keeping data consistent. Twisp provides a few different mechanisms to aid in this:

  • Accounts have an externalId field that will enforce uniqueness.
  • Accounts and Account Sets have a metadata field that's useful for populating with metadata from external systems, especially identifiers
  • Utilize UUID v5 to generate deterministic identifiers for Twisp entities that correspond to their Unit counterparts.

Credit Accounts

Credit accounts are modeled slightly differently than deposit accounts:

In this case a second account, Credit Line is added. This account is where we book a single entry to define the limit of the entire credit account. The other accounts function identically with the normality of the Credit Payable accounts being Debit Normal:

Entry IDAccountAmountDirectionCredit BalancePayable Balance
1Line$1000Credit$1000$0
2Default$500Debit$500$500
3Default$250Credit$750$250
4Default$10Debit$740$260

This gives you two significant balances:

  1. The Credit Balance set gives you a balance that you can use for authorization decisions.
  2. The Credit Payable set gives you a balance that is owed to you by the customer.

004_CreditAccounts

mutation CreateCreditAccount(
  $balanceSetId: UUID!
  $payableSetId: UUID!
  $defaultAccountId: UUID!
  $lineAccountId: UUID!
  $defaultCode: String!
  $lineCode: String!
  $metadata: JSON
  $journalId: UUID! = "00000000-0000-0000-0000-000000000000"
) {
  balanceSet: createAccountSet(
    input: {
      accountSetId: $balanceSetId
      name: "Credit Balance"
      description: "Credit balance roll up for this credit account."
      metadata: $metadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  payableSet: createAccountSet(
    input: {
      accountSetId: $payableSetId
      name: "Payable Balance"
      description: "Outstanding balance owed by account holder."
      metadata: $metadata
      journalId: $journalId
    }
  ) {
    accountSetId
  }
  lineAcct: createAccount(
    input: {
      accountId: $lineAccountId
      name: "Credit Line"
      description: "Apply credit limit to this account."
      code: $lineCode
      metadata: $metadata
    }
  ) {
    accountId
  }
  defaultAccount: createAccount(
    input: {
      accountId: $defaultAccountId
      name: "Default"
      description: "Default credit account"
      code: $defaultCode
      metadata: $metadata
    }
  ) {
    accountId
  }
}

Platform Accounts

When operating a fintech, your organization will partner with a bank which will configure one or more actual bank accounts in order to support your operations. These include, but are not limited to:

AccountDescription
Suspense AccountAn account to post transactions with unknown accounts to.
ACH SettlementSettlement account for ACHs.
Bill Pay SettlementSettlement account for Bill Pay.
Foreign Checks AccountSettlement account for foriegn checks.
Courtesy Credit AccountOperational accounts for crediting accounts via customer service interactions.
Charge Off AccountOperational account to write off closing balances on uncollectable accounts.
Fraud LossesOperational account to write off losses for fraud.
Levies & GarnishmentsAccount to collect levies and garnishments of funds.
Cashiers CheckSettlement account for cashiers checks.
Card DisputesReserve account for handling card related disputes.
ACH DisputesReserve account for handling ACH related disputes.
Interchange RevenueRevenue account for shared interchange.
Collected Fee RevenueRevenue account for fees collected from accounts.
FBO"For Benefit Of", omnibus accounts for specific purpose. For example, virtual wallets.
CashAn asset account that represents all funds created in the system.

These accounts are often the "other side" of your double entry accounting and you could have multiple of these accounts across your various banking partners. For the purpose of this document, we'll consider a system with:

  • Cash account for assets
  • Card Settlement for a single BIN
  • ACH Settlement account for single bank partner
  • Suspense Account
  • Disputes
  • Revenue

There will be a few well known identifiers for these settlement accounts that will be pervasively used through the system.

We recommend creating a library with human readable codes that allow fast look ups to use as parameters to tran codes.

003_PlatformAccounts

mutation PlatformAccounts {
  cash: createAccount(
    input: {
      accountId: "db07e5cf-6cd7-4629-a952-9613578cd8ea"
      code: "ASSETS.CASH"
      name: "Cash account"
      description: "Cash account representing all assets the platform."
      normalBalanceType: DEBIT
      config: { enableConcurrentPosting: true }
    }
  ) {
    accountId
  }
  cardSettlement: createAccount(
    input: {
      accountId: "fabdce1f-6eb9-4630-9472-6338ed3dc6a2"
      code: "SETTLEMENT.VISA"
      name: "Visa Settlement Account"
      description: "Settlement account to use for Visa cards on bin 41200."
      config: { enableConcurrentPosting: true }
    }
  ) {
    accountId
  }
  achSettlement: createAccount(
    input: {
      accountId: "7c923aed-a5fc-4ded-b2f0-57db45a8b545"
      code: "SETTLEMENT.ACH"
      name: "ACH Settlement Account"
      description: "Settlement account to use for Bank Partner 1."
      config: { enableConcurrentPosting: true }
    }
  ) {
    accountId
  }
  suspenseAccount: createAccount(
    input: {
      accountId: "6e341e23-e35f-488c-b947-b7b8839f4c00"
      code: "SUSPENSE"
      name: "Suspense Account"
      description: "Account to post to when an account doesnt exist or is in a non-postable state."
      config: { enableConcurrentPosting: true }
    }
  ) {
    accountId
  }
  disputes: createAccount(
    input: {
      accountId: "08f304d5-6a63-40b9-a182-bfb732994b15"
      code: "DISPUTES"
      name: "Disputes Account"
      description: "Reserve account for dispute resolution."
      config: { enableConcurrentPosting: true }
    }
  ) {
    accountId
  }
  revenueAccount: createAccount(
    input: {
      accountId: "4d79af07-acd9-4a44-8291-78a573ced41d"
      code: "REVENUE"
      name: "Revenue Account"
      description: "Interchange and other fees collected from customer accounts."
      config: { enableConcurrentPosting: true }
    }
  ) {
    accountId
  }
}

Transaction Workflows

When processing transactions, to end users they feel like a singular event. I swipe my card the purchase is made. However that singular event is often a series of transactions, depending on how the swipe was made (Dual vs Single message auth), or if the merchant uses multiple clearings. Card processing can be challenging as the underlying protocol allows for a wide variety of behaviors.

The same is true with many other types of transactions, such as ACH's or checks. The logically singular event may have a lifecycle that encompasses many transactions. These lifecycles we call transaction workflows and here we present a simplified model that you can adapt to your particular card and ach providers.

Primitives

There are a few different primitives built into Twisp for posting transactions that will help us model these the workflows:

  • Tran Codes define entries are posted for a particular transaction type.
  • postTransaction allows you to post a transaction with a specific tran code.
  • voidTransaction posts the entries required to reverse any transaction.
  • workflows allow composition of multiple transaction operations to fulfill specific use cases.

Each of these transaction flows will be composed together of one or more of the above primitives. These workflows are not prescriptive, but are intended to illustrate the concepts to the point where they can be adapted to your own use case and production usage.

Card Authorizations and Transactions

The ISO-8583 specification defines how merchants and issuers interact with each other via card networks. This communication protocol is implemented by a wide variety of vendors, and for the purposes of this document we're going to explore the key workflows required, define the tran codes required to post transaction and illustrate how to utilize those tran codes to fulfill card authorization work flows.

Tran Codes

At the heart of processing transactions in Twisp are the Tran Codes required to make journal entries. Here is a set of tran codes that allow you to completely model card authorization and settlement/clearing.

CodeDescription
CARD_HOLDPost at pending layer between settlement and card accounts.
CARD_SETTLEPost at settled layer between settlement and card accounts.
CARD_HOLD_VOIDOptional $0 entries at pending layer
CARD_HOLD_REPLACEIdentical to CARD_HOLD, but provides labeling differences
CARD_DECLINEOptional $0 entries at pending layer for declines

005_CardTranCodes

mutation CardTranCodes(
  $cardHold: TranCodeInput!
  $cardHoldVoid: TranCodeInput!
  $cardHoldReplace: TranCodeInput!
  $cardSettle: TranCodeInput!
  $cardDecline: TranCodeInput!
) {
  cardHold: createTranCode(input: $cardHold) {
    ...TC
  }

  cardHoldVoid: createTranCode(input: $cardHoldVoid) {
    ...TC
  }

  cardHoldReplace: createTranCode(input: $cardHoldReplace) {
    ...TC
  }

  cardSettle: createTranCode(input: $cardSettle) {
    ...TC
  }

  cardDecline: createTranCode(input: $cardDecline) {
    ...TC
  }
}

fragment TC on TranCode {
  tranCodeId
  code
  description
  params {
    name
    type
    default
    description
  }
  transaction {
    effective
    journalId
    correlationId
    externalId
    description
    metadata
  }
  entries {
    entryType
    accountId
    layer
    direction
    units
    currency
    description
    metadata
    condition
  }
}

Card Authorization Workflows

Card flows in Twisp can be modeled entirely with postTransaction and voidTransaction which are the most common primitives you'll use in Twisp. Together with the set of card tran codes, you can build out very simple card flows that allow you to accurately track the balances and entries required for a card transaction lifecycle.

Using the account we onboarded earlier, we'll post the transactions for each use case and print the resulting balances.

Authorization Approval

You receive an authorization request and/or an advice for $10 that an authorization was approved.

EventOperationTran CodeDescription
auth.requestpostTransactionCARD_HOLDpost $ amount to pending layer
auth.createdvoidTransactionn/avoid previous transaction
...postTransactionCARD_HOLDpost $ amount to pending layer

006_CardAuthorizationApproval

mutation CardAuthorizationApproval(
  $initialAuthorizationId: UUID!
  $authorizationId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $effective: Date
  $initAuthHook: JSON
  $authHook: JSON
) {
  # Received initial authorization webhook
  initialAuthorization: postTransaction(
    input: {
      transactionId: $initialAuthorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $initAuthHook
      }
    }
  ) {
    transactionId
  }

  # Recieved approved webhook
  voidInitialAuthorization: voidTransaction(id: $initialAuthorizationId) {
    transactionId
  }

  authorization: postTransaction(
    input: {
      transactionId: $authorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $authHook
      }
    }
  ) {
    transactionId
  }
}

007_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Authorization Decline

You decide to decline an authorization request or receive an advice that an authorization was declined for $10.

EventOperationTran CodeDescription
auth.createdpostTransactionCARD_HOLDpost $ amount to pending layer
...check balancen/abalance exceeds threshold
...voidTransactionn/avoid transaction
...postTransactionCARD_HOLD_VOIDoptionally Post $0 entry indicating no hold

008_CardAuthorizationDecline

mutation CardAuthorizationDecline(
  $initialAuthorizationId: UUID!
  $declineId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $effective: Date
  $initAuthHook: JSON
  $declineHook: JSON
) {
  # Received initial authorization webhook
  # and return resulting balances.
  initialAuthorization: postTransaction(
    input: {
      transactionId: $initialAuthorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $initAuthHook
      }
    }
  ) {
    transactionId
    entries(first: 4) {
      nodes {
        balance {
          available(layer: PENDING) {
            normalBalance {
              units
            }
          }
        }
      }
    }
  }

  # Declined transaction, later recieved declined webhook
  voidInitialAuthorization: voidTransaction(id: $initialAuthorizationId) {
    transactionId
  }

  decline: postTransaction(
    input: {
      transactionId: $declineId
      tranCode: "CARD_DECLINE"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $declineHook
      }
    }
  ) {
    transactionId
  }
}

009_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Authorization Update

An authorization is approved for $10 and then updated to $1.

EventOperationTran CodeDescription
auth.createdpostTransactionCARD_HOLDpost $ amount to pending layer
auth.updatevoidTransactionn/avoid prior transaction
...postTransactionCARD_HOLD_REPLACEpost new $ amount to pending layer

010_CardAuthorizationUpdate

mutation CardAuthorizationUpdate(
  $initialAuthorizationId: UUID!
  $authorizationId: UUID!
  $updateId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $updateAmount: Decimal
  $effective: Date
  $initAuthHook: JSON
  $authHook: JSON
  $updateHook: JSON
) {
  # Received initial authorization webhook
  initialAuthorization: postTransaction(
    input: {
      transactionId: $initialAuthorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $initAuthHook
      }
    }
  ) {
    transactionId
  }

  # Recieved approved webhook
  voidInitialAuthorization: voidTransaction(id: $initialAuthorizationId) {
    transactionId
  }

  authorization: postTransaction(
    input: {
      transactionId: $authorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $authHook
      }
    }
  ) {
    transactionId
  }

  # Received update webhook
  voidApprovedAuth: voidTransaction(id: $authorizationId) {
    transactionId
  }

  update: postTransaction(
    input: {
      transactionId: $updateId
      tranCode: "CARD_HOLD_REPLACE"
      params: {
        account: $accountId
        amount: $updateAmount
        correlation: $correlation
        effective: $effective
        metadata: $updateHook
      }
    }
  ) {
    transactionId
  }
}

011_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Authorization Cancelation

The authorization is created for $10 that expires or is canceled by the merchant.

EventOperationTran CodeDescription
auth.createdpostTransactionCARD_HOLDpost $ amount to pending layer
auth.cancelvoidTransactionn/avoid prior transaction

012_CardAuthorizationCancel

mutation CardAuthorizationCancel(
  $initialAuthorizationId: UUID!
  $authorizationId: UUID!
  $expireId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $effective: Date
  $initAuthHook: JSON
  $authHook: JSON
  $expireHook: JSON
) {
  # Received initial authorization webhook
  initialAuthorization: postTransaction(
    input: {
      transactionId: $initialAuthorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $initAuthHook
      }
    }
  ) {
    transactionId
  }

  # Recieved approved webhook
  voidInitialAuthorization: voidTransaction(id: $initialAuthorizationId) {
    transactionId
  }

  authorization: postTransaction(
    input: {
      transactionId: $authorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $authHook
      }
    }
  ) {
    transactionId
  }

  # Recieved card expiry webhook
  voidAuthorization: voidTransaction(id: $authorizationId) {
    transactionId
  }

  expire: postTransaction(
    input: {
      transactionId: $expireId
      tranCode: "CARD_HOLD_VOID"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $expireHook
      }
    }
  ) {
    transactionId
  }
}

013_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Settlement (amount >= auth)

A settlement of an existing authorization whose amount is greater than the hold amount. In this case a $10 settlement is applied to the account.

EventOperationTran CodeDescription
auth.createdpostTransactionCARD_HOLDpost $ amount to pending layer
tx.createdpostTransactionCARD_SETTLEpost $ amount to settled layer
...voidTransactionn/avoid prior authorization transaction
...postTransactionCARD_HOLD_VOIDoptionally Post $0 entry indicating no hold

014_CardSettlement

mutation CardSettlement(
  $initialAuthorizationId: UUID!
  $authorizationId: UUID!
  $settleId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $effective: Date
  $initAuthHook: JSON
  $authHook: JSON
  $settleHook: JSON
) {
  # Received initial authorization webhook
  initialAuthorization: postTransaction(
    input: {
      transactionId: $initialAuthorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $initAuthHook
      }
    }
  ) {
    transactionId
  }

  # Recieved approved webhook
  voidInitialAuthorization: voidTransaction(id: $initialAuthorizationId) {
    transactionId
  }

  authorization: postTransaction(
    input: {
      transactionId: $authorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $authHook
      }
    }
  ) {
    transactionId
  }

  # Recieved clearing webhook
  voidAuthorization: voidTransaction(id: $authorizationId) {
    transactionId
  }

  clearing: postTransaction(
    input: {
      transactionId: $settleId
      tranCode: "CARD_SETTLE"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $settleHook
      }
    }
  ) {
    transactionId
  }
}

015_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Multi-settlement (amount < auth)

Some processors always drop holds with a settlement. Others will adjust hold amount and allow for additional settlements. You'll map your implementation to match the card providers.

EventOperationTran CodeDescription
auth.createdpostTransactionCARD_HOLDpost $ amount to pending layer
tx.createdpostTransactionCARD_SETTLEpost $ amount to settled layer
...voidTransactionn/avoid prior authorization transaction
...postTransactionCARD_HOLD_VOIDoptionally Post $0 entry indicating no hold
auth.updatedvoidTransactionn/aoptionally void prior $0 hold
...postTransactionCARD_HOLD_REPLACEoptionally post new hold

016_CardPartialSettlement

mutation CardPartialSettlement(
  $initialAuthorizationId: UUID!
  $authorizationId: UUID!
  $partialSettleId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $replaceAmount: Decimal!
  $settleAmount: Decimal!
  $effective: Date
  $initAuthHook: JSON
  $authHook: JSON
  $settleHook: JSON
) {
  # Received initial authorization webhook
  initialAuthorization: postTransaction(
    input: {
      transactionId: $initialAuthorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $initAuthHook
      }
    }
  ) {
    transactionId
  }

  # Recieved approved webhook
  voidInitialAuthorization: voidTransaction(id: $initialAuthorizationId) {
    transactionId
  }

  authorization: postTransaction(
    input: {
      transactionId: $authorizationId
      tranCode: "CARD_HOLD"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $authHook
      }
    }
  ) {
    transactionId
  }

  # Recieved partial settlement webhook
  voidAuthorization: voidTransaction(id: $authorizationId) {
    transactionId
  }

  updateHold: postTransaction(
    input: {
      transactionId: $partialSettleId
      tranCode: "CARD_HOLD_REPLACE"
      params: {
        account: $accountId
        amount: $replaceAmount
        correlation: $correlation
        effective: $effective
        metadata: $settleHook
      }
    }
  ) {
    transactionId
  }

  partialSettle: postTransaction(
    input: {
      transactionId: "6daad172-6ae7-4ddd-843c-e5e0455fb6da"
      tranCode: "CARD_SETTLE"
      params: {
        account: $accountId
        amount: $settleAmount
        correlation: $correlation
        effective: $effective
        metadata: $settleHook
      }
    }
  ) {
    transactionId
  }
}

017_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Settlement (no Auth)

Receive a transaction without a matching hold.

EventOperationTran CodeDescription
tx.createdpostTransactionCARD_SETTLEpost $ amount to settled layer

018_CardForcePost

mutation CardForcePost(
  $settlementId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $effective: Date
  $settleHook: JSON
) {
  # Received clearing webhook.
  forcePost: postTransaction(
    input: {
      transactionId: $settlementId
      tranCode: "CARD_SETTLE"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $settleHook
      }
    }
  ) {
    transactionId
  }
}

019_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

Return

Receive a return transaction.

EventOperationTran CodeDescription
tx.createdpostTransactionCARD_SETTLEpost $ amount to settled layer

020_CardReturn

mutation CardReturn(
  $settlementId: UUID!
  $accountId: UUID!
  $correlation: String
  $direction: String
  $amount: Decimal!
  $effective: Date
  $settleHook: JSON
) {
  # Received clearing webhook.
  forcePost: postTransaction(
    input: {
      transactionId: $settlementId
      tranCode: "CARD_SETTLE"
      params: {
        account: $accountId
        amount: $amount
        correlation: $correlation
        effective: $effective
        metadata: $settleHook
        direction: $direction
      }
    }
  ) {
    transactionId
  }
}

021_CheckBalance

query CheckBalance($accountId: UUID! = "7a17b0f1-b04f-46a4-ba45-d52a861c21d9") {
  balance(accountId: $accountId) {
    settled: available(layer: SETTLED) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
    pending: available(layer: PENDING) {
      normalBalance {
        formatted(as: { locale: "en-US" })
      }
    }
  }
}

ACH

The Automated Clearing House is a service provided by the Federal Reserve for conducting electronic funds transfers. There are two modes of operation when dealing with ACH:

  • Receiving Deposit Financial Institution (RDFI): External FI's are crediting/debiting your accounts with theirs
  • Originating Deposit Financial Institution (ODFI): You are initiating credits/debits with external accounts

Twisp has a number of built in tran codes and workflows to handle both use cases.

Tran Codes

022_ACHTranCodes

query ACHTranCodes {
  achEncumbranceCancelDebit: tranCode(
    id: "7b24410d-6dbc-44cd-8779-a1bfa827868a"
  ) {
    ...TC
  }
  achEncumbranceCancelReversalCredit: tranCode(
    id: "b7976c0e-2e4c-4e99-a48f-86384f3dddf6"
  ) {
    ...TC
  }
  achEncumbranceCredit: tranCode(id: "8fe41dca-190a-4c84-adc9-78952ac70a54") {
    ...TC
  }
  achEncumbranceDebit: tranCode(id: "0b83d0d4-b580-4056-a094-74ef393328b7") {
    ...TC
  }
  achEncumbranceReturnDebit: tranCode(
    id: "cde02e54-e725-46bc-9b61-5511c93058a7"
  ) {
    ...TC
  }
  achEncumbranceReturnCredit: tranCode(
    id: "4c4d9599-1caa-4632-a6fb-ca1ae3a2b836"
  ) {
    ...TC
  }
  achEncumbranceReversalDebit: tranCode(
    id: "cd7b997c-526c-4b8f-ac56-8c6de009dc25"
  ) {
    ...TC
  }
  achEncumbranceReversalCredit: tranCode(
    id: "f57ba3a2-ccfa-47de-ae81-1a8167639fe3"
  ) {
    ...TC
  }
  achFeeDebit: tranCode(id: "d3b64ee6-0815-4d06-aa9b-906bc48cbc19") {
    ...TC
  }
  achFeeReimburseCredit: tranCode(id: "49b0f890-75e7-4b94-920b-54328c02b2ca") {
    ...TC
  }
  achPendingDebit: tranCode(id: "e19dfe3f-6513-425a-b9a3-870f91303cc8") {
    ...TC
  }
  achPendingCancelCredit: tranCode(id: "c66725d7-c115-4339-89e9-69b6a6ae97bd") {
    ...TC
  }
  achPendingCancelReversalDebit: tranCode(
    id: "f83a1768-06a1-4a6b-a472-6e9b9c7dbe75"
  ) {
    ...TC
  }
  achPendingReversalDebit: tranCode(
    id: "21036f96-bc05-4526-8004-e63ff6955d0a"
  ) {
    ...TC
  }
  achSettleCredit: tranCode(id: "d918eb34-1ef9-4437-a82b-46c169f63e41") {
    ...TC
  }
  achSettleDebit: tranCode(id: "5af51352-84ad-4534-aacf-4e68c2ed2e2b") {
    ...TC
  }
  achSettleReturnCredit: tranCode(id: "90e09ed8-ae9c-46f2-9be8-2e09b4a5de35") {
    ...TC
  }
  achSettleReturnDebit: tranCode(id: "2b11b428-54ed-429c-97d9-b9a4e3ef6007") {
    ...TC
  }
}
fragment TC on TranCode {
  tranCodeId
  code
  description
  params {
    name
    type
    default
    description
  }
  transaction {
    effective
    journalId
    correlationId
    externalId
    description
    metadata
  }
  entries {
    entryType
    accountId
    layer
    direction
    units
    currency
    description
    metadata
    condition
  }
}

Workflows

In addition to the built-in tran codes, Twisp offers a number of workflows that will post & void transactions as appropriate for each stage of the ACH transaction lifecycle. These are built on top of the executeTask workflow invocation.

Below we will look at sample graphql for invocation of the workflow and describe the various tasks available for each type of workflow.

ODFI Push

TaskValid FromDescription
CREATEDebit customers account at PENDING layer, funds leaving in next ACH batch.
SUBMITCREATEVoid the PENDING transaction and post settlement, funds are sent to external institution.
RETURNSUBMITExternal institution returned the funds for some reason. e.g. Account is closed.
CANCELCREATECancel the ACH transaction before the batch window is reached.
CONTINUECANCEL, REIMBURSE_FEEUndo the cancellation.
REIMBURSE_FEECREATE, SUBMIT, RETURNReimburse optional fee to the customer.

023_ODFIPush

mutation ODFIPush(
  $accountId: UUID!
  $journalId: UUID!
  $settlementAccountId: UUID!
  $feeAccountId: UUID!
) {
  # Push is scheduled
  create: workflow {
    executeTask(
      input: {
        task: "CREATE"
        workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
        executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
        params: {
          accountId: $accountId
          feeAccountId: $feeAccountId # optional fee accountId for fees
          settlementAccountId: $settlementAccountId
          journalId: $journalId
          amount: "1.00"
          # optional fee amount
          feeAmount: "0.50"
          effective: "2023-01-01"
          metadata: "{}"
        }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # Fee can be reimbursed after CREATE
  # reimburse: workflow {
  #   executeTask(
  #     input: {
  #       task: "REIMBURSE_FEE"
  #       workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
  #       executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #    ) {
  #      transactions {
  #        transactionId
  #      }
  #    }
  # }

  # cancel will reimburse fee if not reimbursed yet

  # cancel: workflow {
  #   executeTask(
  #     input: {
  #       task: "CANCEL"
  #       workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
  #       executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #    ) {
  #      transactions {
  #        transactionId
  #      }
  #    }
  # }

  # continue allows workflow to progress to submit
  # the fee will still apply depending on whether a REIMBURSE_FEE occurred

  # continue: workflow {
  #   executeTask(
  #     input: {
  #       task: "CONTINUE"
  #       workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
  #       executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #    ) {
  #      transactions {
  #        transactionId
  #      }
  #    }
  # }

  # submit means in an ach file on the way to Fed.
  submit: workflow {
    executeTask(
      input: {
        task: "SUBMIT"
        workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
        executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # fee can be reimbursed after SUBMIT

  # reimburse: workflow {
  #   executeTask(
  #     input: {
  #       task: "REIMBURSE_FEE"
  #       workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
  #       executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #    ) {
  #      transactions {
  #        transactionId
  #      }
  #    }
  # }

  # The external institution returned the ACH.
  return: workflow {
    executeTask(
      input: {
        task: "RETURN"
        workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
        executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # fee can be reimbursed after RETURN

  # reimburse: workflow {
  #   execute(
  #     input: {
  #       task: "REIMBURSE_FEE"
  #       workflowId: "934498b5-b4f1-46c4-ad79-868939dc39e8"
  #       executionId: "34d499dd-d061-42f4-af42-c051c8d4109e"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #   )
  # }
}

ODFI Pull

StateValid FromDescription
CREATECredit customer account from an external account at ENCUMBRANCE layer.
CANCELCREATECancel transfer if before ACH batch cutoff.
SUBMITCREATESubmit transfer in ACH file. Post at PENDING layer until settlement.
SETTLESUBMITAfter 3 days without return, post transfer to SETTLED layer.
RETURNSUBMIT, SETTLEIf received a return, send funds from customer account back to settlement account.

024_ODFIPull

mutation ODFIPull(
  $accountId: UUID!
  $journalId: UUID!
  $settlementAccountId: UUID!
) {
  # Transfer will happen in next batch. Funds credited at ENCUMBRANCE layer
  create: workflow {
    executeTask(
      input: {
        task: "CREATE"
        workflowId: "064e3b76-6072-451e-aace-5a7be3704ee2"
        executionId: "3d4875e5-119d-47fe-9a5c-a0064ba8c5a8"
        params: {
          accountId: $accountId
          settlementAccountId: $settlementAccountId
          journalId: $journalId
          amount: "1.00"
          effective: "2023-01-01"
          metadata: "{}"
        }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # Can cancel after create

  # cancel: workflow {
  #   executeTask(
  #     input: {
  #       task: "CANCEL"
  #       workflowId: "064e3b76-6072-451e-aace-5a7be3704ee2"
  #       executionId: "3d4875e5-119d-47fe-9a5c-a0064ba8c5a8"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #   ) {
  #     transactions {
  #      transactionId
  #     }
  #   }
  # }

  # Can undo cancellation

  # continue: workflow {
  #   executeTask(
  #     input: {
  #       task: "CONTINUE"
  #       workflowId: "064e3b76-6072-451e-aace-5a7be3704ee2"
  #       executionId: "3d4875e5-119d-47fe-9a5c-a0064ba8c5a8"
  #       params: {
  #         effective: "2023-01-01"
  #         metadata: "{}"
  #       }
  #     }
  #   ) {
  #    transactions {
  #      transactionId
  #    }
  #  }
  # }

  # Transfer is in the ACH file. Funds are credited at PENDING layer.
  submit: workflow {
    executeTask(
      input: {
        task: "SUBMIT"
        workflowId: "064e3b76-6072-451e-aace-5a7be3704ee2"
        executionId: "3d4875e5-119d-47fe-9a5c-a0064ba8c5a8"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # Invoked after three days without return. Funds are credited at SETTLED layer.
  settle: workflow {
    executeTask(
      input: {
        task: "SETTLE"
        workflowId: "064e3b76-6072-451e-aace-5a7be3704ee2"
        executionId: "3d4875e5-119d-47fe-9a5c-a0064ba8c5a8"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # A return is recieved, move funds back to settlement account.
  return: workflow {
    executeTask(
      input: {
        task: "RETURN"
        workflowId: "064e3b76-6072-451e-aace-5a7be3704ee2"
        executionId: "3d4875e5-119d-47fe-9a5c-a0064ba8c5a8"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }
}

RDFI Credit

StateValid FromDescription
CREATECreates an encumbrance credit, funds will deposit into account.
SETTLECREATEFunds are now settled.
RETURNSETTLE, CREATEReturned funds to originating institution.

025_RDFICredit

mutation RDFICredit(
  $accountId: UUID!
  $journalId: UUID!
  $settlementAccountId: UUID!
) {
  # ACH is in file effective for future, put in PENDING layer
  create: workflow {
    executeTask(
      input: {
        task: "CREATE"
        workflowId: "44d71180-6db2-437e-9083-6ba672f41ba2"
        executionId: "779b773c-f7a3-41f3-905b-5934dcda8932"
        params: {
          accountId: $accountId
          settlementAccountId: $settlementAccountId
          journalId: $journalId
          amount: "1.00"
          effective: "2023-01-01"
          metadata: "{}"
        }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # Reached the effective date, put into SETTLED layer.
  settle: workflow {
    executeTask(
      input: {
        task: "SETTLE"
        workflowId: "44d71180-6db2-437e-9083-6ba672f41ba2"
        executionId: "779b773c-f7a3-41f3-905b-5934dcda8932"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  # Initiate a return of funds to the settlement account.
  return: workflow {
    executeTask(
      input: {
        task: "RETURN"
        workflowId: "44d71180-6db2-437e-9083-6ba672f41ba2"
        executionId: "779b773c-f7a3-41f3-905b-5934dcda8932"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }
}

RDFI Debit

StateValid FromDescription
CREATECreates and encumbrance debit, funds will debit from account.
SETTLECREATEFunds are now settled.
RETURNCREATE, SETTLEInitiated a return, funds credit back to customer account.

026_RDFIDebit

mutation RDFIDebit(
  $accountId: UUID!
  $journalId: UUID!
  $settlementAccountId: UUID!
) {
  create: workflow {
    executeTask(
      input: {
        task: "CREATE"
        workflowId: "c9085474-0b55-4bda-a279-04535d7cb8b7"
        executionId: "e3271c9d-cdcf-4f38-af96-a3916219f611"
        params: {
          accountId: $accountId
          settlementAccountId: $settlementAccountId
          journalId: $journalId
          amount: "1.00"
          effective: "2023-01-01"
          metadata: "{}"
        }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  settle: workflow {
    executeTask(
      input: {
        task: "SETTLE"
        workflowId: "c9085474-0b55-4bda-a279-04535d7cb8b7"
        executionId: "e3271c9d-cdcf-4f38-af96-a3916219f611"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  return: workflow {
    executeTask(
      input: {
        task: "RETURN"
        workflowId: "c9085474-0b55-4bda-a279-04535d7cb8b7"
        executionId: "e3271c9d-cdcf-4f38-af96-a3916219f611"
        params: { effective: "2023-01-01", metadata: "{}" }
      }
    ) {
      transactions {
        entries(first: 4) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }
}

Clearing Settlements

The cash account is an important one for double entry accounting. This is account is the mechanism for getting money from the outside world into and out of your system. In our case there are several accounts that may interact with the outside world and change the total cash position of the system:

  • Settlement Accounts
    • Cards
    • ACH
  • Revenue accounts
  • Disputes and other operational accounts

These accounts will be "cleared" in bulk with the aggregate amount at the time of clearing. Let's say you have $2M in card debits on a particular day across 2 transactions. Where your starting cash balance is $100M

DRCRBalances
$2M Card Settle$2M CashCard Settle ($2M), Cash $98
$1M Deposit Acct 1$1M Card SettleCard Settle($1M)
$1m Deposit Acct 2$1M Card SettleCard Settle $0

This bulk clearing to get funds on/off the platform should be conducted with it's own tran codes. These settlement accounts are often backed by an account at a bank, and so the balances of these accounts should be reconciled with your representation of them in your accounting system.

027_ClearingTranCode

mutation ClearingTranCode($clearing: TranCodeInput!) {
  createTranCode(input: $clearing) {
    ...TC
  }
}

fragment TC on TranCode {
  tranCodeId
  code
  description
  params {
    name
    type
    default
    description
  }
  transaction {
    effective
    journalId
    correlationId
    externalId
    description
    metadata
  }
  entries {
    entryType
    accountId
    layer
    direction
    units
    currency
    description
    metadata
    condition
  }
}

Advanced Topics

As we've seen, Tran Codes are a powerful abstraction to encapsulate accounting concerns inside of Twisp. We also explored one workflow that Twisp offers for ACH transactions, to automatically handle posting and voiding transactions to a customers account for each part of an ACH lifecycle.

Twisp offers several other workflows that may be useful for interacting with the accounting core. In this section we'll briefly examine each one.

Void And Post Workflow

The Void and Post workflow is a powerful mechanism for modeling a transaction that may change layers and amounts over time. In essence a single transaction identifier, the executionId passed to the workflow, will void the prior transaction created by the workflow and create a new transaction with the tran code you've provided to the workflow.

This allows for some powerful transaction modeling. Consider the earlier section on card holds. In that you may be composing together two graphql queries:

  • voidTransaction to void the "prior authorization"
  • postTransaction to post the new authorization value

With the void and post workflow, we can use a single identifier for the "authorization" and update it without needing to keep track of multiple transaction ids. Consider this example which posts an authorization and then updates it.

028_VoidAndPost

mutation VoidAndPost(
  $authorizationId: UUID!
  $accountId: UUID!
  $correlation: String
  $amount: Decimal!
  $updatedAmount: Decimal!
  $effective: Date
) {
  originalAuth: workflow {
    executeTask(
      input: {
        workflowId: "c97010ac-f703-4112-8bb3-493ec0c2dfd4"
        executionId: $authorizationId
        task: "VOID_AND_POST"
        params: {
          tranCode: "CARD_HOLD"
          account: $accountId
          amount: $amount
          correlation: $correlation
          effective: $effective
        }
      }
    ) {
      transactions {
        transactionId
        entries(first: 10) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }

  updatedAuth: workflow {
    executeTask(
      input: {
        workflowId: "c97010ac-f703-4112-8bb3-493ec0c2dfd4"
        executionId: $authorizationId
        task: "VOID_AND_POST"
        params: {
          tranCode: "CARD_HOLD"
          account: $accountId
          amount: $updatedAmount
          correlation: $correlation
          effective: $effective
        }
      }
    ) {
      transactions {
        transactionId
        entries(first: 10) {
          nodes {
            entryType
            direction
            amount {
              units
            }
          }
        }
      }
    }
  }
}

States Language Workflow

GraphQL is an extremely expressive querying language, however sometimes it requires multiple interactions to serve a request. Take for example the card authorization use case:

  • Optimistically post transaction
  • Check if any balances are violated
  • Void if one of the balances is violated
  • Indicate in response if transaction is Approved or Declined

There could possibly be 2-3 network calls to Twisp to fulfill this use case with just the GraphQL api interacting with your application. However, if you want to accomplish all of this transactionally in Twisp, we support defining state machines using States Language that allows you to cooridnate multiple api calls.

In the following example:

  • post a CARD_HOLD optimistically to a card account
  • check the balance of the parent deposit account set
  • if the balance is less than zero we void
  • return either a Declined or Approved message based on the balance

This feature is not yet GA but we're looking to ship in the next few days.

SKIP029_StatesLanguage

mutation PostCheckAndVoid(
  $authorizationVariables: JSON!
  $machine: StateMachine!
) {
  workflow {
    executeStatesLanguage(
      input: { input: $authorizationVariables, machine: $machine }
    ) {
      output
    }
  }
}

Custom Balance Computations

By default Twisp rolls up balances on a number of dimensions:

  • journal
  • account
  • currency

Twisp offers the ability to compute balances on additional dimensions. For example, often fintechs will put limits in per MCC code or perhaps on daily, weekly, monthly or yearly spending limits. These are supported in Twisp via Balance Calculations. Once you've created a balance calcuation, you may look up balance on that calculation via the balances endpoint by supplying the calculationId and dimension values you're interested in. For example, for the above calculation:

query GetJan12020EffectiveBalance($accountId: UUID!) {
  balance(
    accountId: $accountId
    currency: "USD"
    calculationId: "5867b5dd-fc69-416c-80f5-62e8a53610d5" 
    dimension: {
        effectiveDate: "2020-01-01"
    }
  ) {
    available(layer:PENDING) {
        normalBalance {
            units
        }
    }
  }
}