8 min read

Building a BaaS Ledger, Part 2: Transaction Workflows

A workflow line of ledger steps ending in a committed transaction

In Part 1, we designed our BaaS' ledger architecture. The habit now is to take the inbound credit, the payout, or the book transfer and just insert a row.

However, deposits, withdrawals, transfers, card payments are more akin to workflows, not just writes. A deposit is triggered by a notification from your bank, then you credit the customer, take tax, and take a fee. A withdrawal has to hold the money, run a check, call the rail, then commit or give it back depending on its success state. If you treat them simply as row inserts, you could lose control on all of the logic that accompanies the record.

This article is how you think about those workflows, plan them, and implement them on your ledger. All of the code is Blnk Core, our open-source ledger.

Mental model: money movement map

A money movement map is how you plan a workflow before you post anything. You list everything that must happen when it starts. If it is supposed to happen, it is on the map. If it is not on the map, it does not concern the ledger.

Take a deposit. Jordan Ellis sends $250 into an account OrbitPay issued. What has to happen: the customer gets a credit, you collect tax, you earn your fee. Nothing else. Then you ask how each of those things should happen. Should Jordan see only the $246.50 land (net amount), or should he see $250 in (gross amount) and the cuts leave afterwards? That choice is what your map helps you visualize:

Net amount. @WorldUSD_Unit splits once: customer, revenue, tax. The fee never touches his balance.

flowchart LR World["@WorldUSD_Unit"] --> Customer["Customer account"] World --> Revenue["@RevenueUSD_BaaS"] World --> Tax["@TaxUSD_BaaS"]
Net amount in

Fees on the statement. @WorldUSD_Unit credits him in full. Then his checking pays revenue and tax.

flowchart LR World["@WorldUSD_Unit"] --> Customer["Customer account"] Customer --> Revenue["@RevenueUSD_BaaS"] Customer --> Tax["@TaxUSD_BaaS"]
Gross amount first

Same four balances. Different movements. If you later change what he sees, e.g. a new fee, you update the map to see how it affects your workflow.

With your map ready, you can move on to recording the transactions as your map defined. To ensure correctness, here are two rules to keep in mind as you do this:

  1. Double-entry. Every movement has two sides. Money leaves a source and enters a destination, in the same amount. If Jordan is credited $246.50, something else was debited $246.50. A single increment on a balance column cannot prove the $2.50 fee left the inbound rail.
  2. Immutability. Transactions, once recorded, should never be edited or deleted. A correction, reversal, refund, etc. should create a new record that corrects the balance. For example: if the fee was wrong, you refund the $0.50 difference back. You should not directly edit $2.50 to $2.00 on the original fee amount.

Continuing our example

We'll continue with OrbitPay from Part 1. Remember, they're our BaaS client. Their org_id is org_7b22e0. Since Part 1, they've opened a couple of accounts, Jordan Ellis and Maya Chen, both in the OrbitPay_Accounts ledger.

The structure is you expose an endpoint; they call the endpoint to trigger a transfer; your middleware talks to your ledger. However, your middleware should look out for two things before we start any workflow in the ledger:

  1. Does this organization exist in your ledger? Does it have a ledger folder?
  2. If yes, does the affected balance belong to the organization ledger folder?

If either answer is no, the request should fail. If both are yes, you can start the workflow.

baasMiddleware.ts
function orgHasFolder(orgId) {
  return getLedgerByOrg(orgId) != null;
}

function folderOwnsBalance(orgId, balanceId) {
  const folder = getLedgerByOrg(orgId);
  const balance = getBalance(balanceId);
  return balance.ledger_id === folder.ledger_id;
}

if (!orgHasFolder('org_7b22e0')) {
  throw new Error('org is not on this ledger');
}

if (!folderOwnsBalance('org_7b22e0', 'bln_jordan-ellis-checking')) {
  throw new Error('balance is not in OrbitPay_Accounts');
}

// both checks passed. start the workflow.

Workflow 1: Deposit

You receive a deposit notification of $250 belonging to OrbitPay's Jordan Ellis. You have decided to only deposit the net amount, and strip off any fees before it gets to the user account. Let's say you charge a $2.50 fee and $1.00 in tax: he should only see $246.50, not a $250 credit and two debits.

flowchart LR World["@WorldUSD_Unit"] --> Customer["Customer account"] World --> Revenue["@RevenueUSD_BaaS"] World --> Tax["@TaxUSD_BaaS"]
Deposit

We'll record it in Blnk as one split from @WorldUSD_Unit. That's an internal balance representing that the money came from outside our system. Same as @RevenueUSD_BaaS and @TaxUSD_BaaS. Money comes in from outside, then it lands on Jordan, revenue, and tax.

You set inflight because you may still want to do a few checks before making the amount available to the customer.

createDeposit.ts
const deposit = await blnk.Transactions.create({
  precise_amount: 25000,
  precision: 100,
  reference: 'dep_ach_8841',
  currency: 'USD',
  source: '@WorldUSD_Unit',
  allow_overdraft: true,
  inflight: true,
  destinations: [
    {
      identifier: '@RevenueUSD_BaaS',
      precise_distribution: '250',
      narration: 'Deposit fee',
    },
    {
      identifier: '@TaxUSD_BaaS',
      precise_distribution: '100',
      narration: 'Deposit tax',
    },
    {
      identifier: 'bln_jordan-ellis-checking',
      distribution: 'left',
      narration: 'Deposit, OrbitPay Accounts',
    },
  ],
  meta_data: {
    org_id: 'org_7b22e0',
    product: 'Accounts',
    customer_id: 'cus_8a2f41',
    customer_name: 'Jordan Ellis',
  },
});
FieldWhat it does
source@WorldUSD_Unit. Where the inbound money enters the book.
destinationsJordan, @RevenueUSD_BaaS, and @TaxUSD_BaaS on one movement.
inflight: trueHold it while you check the deposit isn't fishy or risky.
referenceA retry of dep_ach_8841 posts once.
allow_overdraft@WorldUSD_Unit can go negative. Represents the cash at the bank.

Commit or void when the check has an answer:

updateDeposit.ts
await blnk.Transactions.updateStatus(deposit.data.transaction_id, {
  status: checksPassed ? 'commit' : 'void',
});
FieldWhat it does
commitThe deposit cleared. $246.50 is spendable.
voidThe check failed. The funds never gets to Jordan.

Workflow 2: Withdrawal

OrbitPay hits the endpoint you exposed. Jordan wants $100 out. You charge a $1.00 fee per withdrawal, so $101 total has to leave his checking account. To keep it simple, our example withdrawal also leaves through @WorldUSD_Unit, the same internal balance you used for the deposit.

flowchart LR Customer["Customer account"] --> World["@WorldUSD_Unit"] Customer --> Revenue["@RevenueUSD_BaaS"]
Withdrawal

Record the split inflight first. $1.00 to your revenue, $100.00 to @WorldUSD_Unit. Nothing has left the bank yet.

createWithdrawal.ts
const withdrawal = await blnk.Transactions.create({
  precise_amount: 10100,
  precision: 100,
  reference: 'wd_ach_2290',
  currency: 'USD',
  source: 'bln_jordan-ellis-checking',
  inflight: true,
  atomic: true,
  destinations: [
    {
      identifier: '@RevenueUSD_BaaS',
      precise_distribution: '100',
      narration: 'Withdrawal fee',
    },
    {
      identifier: '@WorldUSD_Unit',
      distribution: 'left',
      narration: 'Withdrawal, OrbitPay Accounts',
    },
  ],
  meta_data: {
    org_id: 'org_7b22e0',
    product: 'Accounts',
    customer_id: 'cus_8a2f41',
    customer_name: 'Jordan Ellis',
  },
});
FieldWhat it does
inflight: trueHold $101 while you run the check. The rail has not been called.
destinations$1.00 to your revenue, $100.00 to @WorldUSD_Unit.
referenceThis request posts once. A second, similar withdraw request should be ignored by your ledger

Then you run your check. If it fails, void and stop. He can spend the $101 again. You never called the rail.

voidWithdrawal.ts
if (!checksPassed) {
  await blnk.Transactions.updateStatus(withdrawal.data.transaction_id, {
    status: 'void',
  });
  return;
}
FieldWhat it does
voidReleases the hold. The $101 is available again.

If it passed, now you send. The payout provider gets $100. Same reference as the hold.

createPayout.ts
const payout = await payoutProvider.createPayout({
  amount: 100,
  currency: 'USD',
  reference: 'wd_ach_2290',
});
FieldWhat it does
amount$100 to the rail. Your $1.00 fee already sat on revenue.
referenceSame id as the hold, so you can match the payout to this withdrawal.

When they answer, commit or void. Both are new writes. The hold row stays.

updateWithdrawal.ts
await blnk.Transactions.updateStatus(withdrawal.data.transaction_id, {
  status: payout.succeeded ? 'commit' : 'void',
});
FieldWhat it does
commitThe rail left. The $101 stays off his checking.
voidThe rail failed. The $101 is available again.

Workflow 3: Internal transfer

OrbitPay hits your internal transfer endpoint you exposed. Jordan sends $40 to Maya Chen. There is no rail. You post it directly on your ledger.

flowchart LR Jordan["Jordan checking"] --> Maya["Maya checking"]
Internal transfer

His checking is the source. Hers is the destination. If a check sits in the middle, hold the $40. If available cannot cover it, reject it.

createTransfer.ts
if (!folderOwnsBalance('org_7b22e0', 'bln_jordan-ellis-checking')) {
  throw new Error('source is not in OrbitPay_Accounts');
}
if (!folderOwnsBalance('org_7b22e0', 'bln_maya-chen-checking')) {
  throw new Error('destination is not in OrbitPay_Accounts');
}
if (getBalance('bln_jordan-ellis-checking').ledger_id !== getBalance('bln_maya-chen-checking').ledger_id) {
  throw new Error('balances are not in the same org');
}

const transfer = await blnk.Transactions.create({
  precise_amount: 4000,
  precision: 100,
  reference: 'trf_op_4412',
  currency: 'USD',
  source: 'bln_jordan-ellis-checking',
  destination: 'bln_maya-chen-checking',
  inflight: true,
  description: 'Transfer, OrbitPay Accounts',
  meta_data: {
    org_id: 'org_7b22e0',
    product: 'Accounts',
    from_customer_id: 'cus_8a2f41',
    to_customer_id: 'cus_3c91aa',
  },
});
FieldWhat it does
source / destinationJordan out, Maya in. Required.
inflight: trueThe $40 is reserved until the check finishes, e.g. check if both balances are in the same organization ledger.
updateTransfer.ts
await blnk.Transactions.updateStatus(transfer.data.transaction_id, {
  status: checksPassed ? 'commit' : 'void',
});
FieldWhat it does
commitThe check passed. Maya has the $40.
voidThe check failed. Jordan can spend it again.

Conclusion

Now, you've learned how to correctly model transaction workflows for your BaaS ledger. You carefully and logically list the steps/events, draw the money movement map, record the transaction, and scope it correctly to the right organization. This approach works for any transaction workflow: card authorization, escrow, bulk disbursements, bills payment, etc.

While we trust our ledger, we have to deal with others too. And the business of money movement is a zero-trust environment. Every claim must be verified, and that is what reconciliation does. In Part 3, we'll discuss how to reconcile your recorded BaaS ledger transactions with your sponsor bank statement and other external payment providers.