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:
- Deposit and Credit accounts
- Card transactions
- 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:
- End User: Track end user activity and control how balances "roll up" for end users of our system.
- 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:
- An Account Set to encapsulate the total balance of the entity in question.
- 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 Received | Actions in Twisp |
---|---|
customer.created | Create customer account |
account.created | Create deposit account, add to customer account |
card.created | Create 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 ID | Account | Amount | Direction | Credit Balance | Payable Balance |
---|---|---|---|---|---|
1 | Line | $1000 | Credit | $1000 | $0 |
2 | Default | $500 | Debit | $500 | $500 |
3 | Default | $250 | Credit | $750 | $250 |
4 | Default | $10 | Debit | $740 | $260 |
This gives you two significant balances:
- The
Credit Balance
set gives you a balance that you can use for authorization decisions. - 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:
Account | Description |
---|---|
Suspense Account | An account to post transactions with unknown accounts to. |
ACH Settlement | Settlement account for ACHs. |
Bill Pay Settlement | Settlement account for Bill Pay. |
Foreign Checks Account | Settlement account for foriegn checks. |
Courtesy Credit Account | Operational accounts for crediting accounts via customer service interactions. |
Charge Off Account | Operational account to write off closing balances on uncollectable accounts. |
Fraud Losses | Operational account to write off losses for fraud. |
Levies & Garnishments | Account to collect levies and garnishments of funds. |
Cashiers Check | Settlement account for cashiers checks. |
Card Disputes | Reserve account for handling card related disputes. |
ACH Disputes | Reserve account for handling ACH related disputes. |
Interchange Revenue | Revenue account for shared interchange. |
Collected Fee Revenue | Revenue account for fees collected from accounts. |
FBO | "For Benefit Of", omnibus accounts for specific purpose. For example, virtual wallets. |
Cash | An 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.
Code | Description |
---|---|
CARD_HOLD | Post at pending layer between settlement and card accounts. |
CARD_SETTLE | Post at settled layer between settlement and card accounts. |
CARD_HOLD_VOID | Optional $0 entries at pending layer |
CARD_HOLD_REPLACE | Identical to CARD_HOLD, but provides labeling differences |
CARD_DECLINE | Optional $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.
Event | Operation | Tran Code | Description |
---|---|---|---|
auth.request | postTransaction | CARD_HOLD | post $ amount to pending layer |
auth.created | voidTransaction | n/a | void previous transaction |
... | postTransaction | CARD_HOLD | post $ 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
auth.created | postTransaction | CARD_HOLD | post $ amount to pending layer |
... | check balance | n/a | balance exceeds threshold |
... | voidTransaction | n/a | void transaction |
... | postTransaction | CARD_HOLD_VOID | optionally 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
auth.created | postTransaction | CARD_HOLD | post $ amount to pending layer |
auth.update | voidTransaction | n/a | void prior transaction |
... | postTransaction | CARD_HOLD_REPLACE | post 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
auth.created | postTransaction | CARD_HOLD | post $ amount to pending layer |
auth.cancel | voidTransaction | n/a | void 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
auth.created | postTransaction | CARD_HOLD | post $ amount to pending layer |
tx.created | postTransaction | CARD_SETTLE | post $ amount to settled layer |
... | voidTransaction | n/a | void prior authorization transaction |
... | postTransaction | CARD_HOLD_VOID | optionally 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
auth.created | postTransaction | CARD_HOLD | post $ amount to pending layer |
tx.created | postTransaction | CARD_SETTLE | post $ amount to settled layer |
... | voidTransaction | n/a | void prior authorization transaction |
... | postTransaction | CARD_HOLD_VOID | optionally Post $0 entry indicating no hold |
auth.updated | voidTransaction | n/a | optionally void prior $0 hold |
... | postTransaction | CARD_HOLD_REPLACE | optionally 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
tx.created | postTransaction | CARD_SETTLE | post $ 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.
Event | Operation | Tran Code | Description |
---|---|---|---|
tx.created | postTransaction | CARD_SETTLE | post $ 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
Task | Valid From | Description |
---|---|---|
CREATE | Debit customers account at PENDING layer, funds leaving in next ACH batch. | |
SUBMIT | CREATE | Void the PENDING transaction and post settlement, funds are sent to external institution. |
RETURN | SUBMIT | External institution returned the funds for some reason. e.g. Account is closed. |
CANCEL | CREATE | Cancel the ACH transaction before the batch window is reached. |
CONTINUE | CANCEL, REIMBURSE_FEE | Undo the cancellation. |
REIMBURSE_FEE | CREATE, SUBMIT, RETURN | Reimburse 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
State | Valid From | Description |
---|---|---|
CREATE | Credit customer account from an external account at ENCUMBRANCE layer. | |
CANCEL | CREATE | Cancel transfer if before ACH batch cutoff. |
SUBMIT | CREATE | Submit transfer in ACH file. Post at PENDING layer until settlement. |
SETTLE | SUBMIT | After 3 days without return, post transfer to SETTLED layer. |
RETURN | SUBMIT, SETTLE | If 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
State | Valid From | Description |
---|---|---|
CREATE | Creates an encumbrance credit, funds will deposit into account. | |
SETTLE | CREATE | Funds are now settled. |
RETURN | SETTLE, CREATE | Returned 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
State | Valid From | Description |
---|---|---|
CREATE | Creates and encumbrance debit, funds will debit from account. | |
SETTLE | CREATE | Funds are now settled. |
RETURN | CREATE, SETTLE | Initiated 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
DR | CR | Balances |
---|---|---|
$2M Card Settle | $2M Cash | Card Settle ($2M), Cash $98 |
$1M Deposit Acct 1 | $1M Card Settle | Card Settle($1M) |
$1m Deposit Acct 2 | $1M Card Settle | Card 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
orApproved
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
}
}
}
}