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.
Fees on the statement. @WorldUSD_Unit credits him in full. Then his checking pays revenue and tax.
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:
- 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.
- 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:
- Does this organization exist in your ledger? Does it have a ledger folder?
- 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.
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.
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.
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',
},
}); | Field | What it does |
|---|---|
| source | @WorldUSD_Unit. Where the inbound money enters the book. |
| destinations | Jordan, @RevenueUSD_BaaS, and @TaxUSD_BaaS on one movement. |
| inflight: true | Hold it while you check the deposit isn't fishy or risky. |
| reference | A 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:
await blnk.Transactions.updateStatus(deposit.data.transaction_id, {
status: checksPassed ? 'commit' : 'void',
}); | Field | What it does |
|---|---|
| commit | The deposit cleared. $246.50 is spendable. |
| void | The 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.
Record the split inflight first. $1.00 to your revenue, $100.00 to @WorldUSD_Unit. Nothing has left the bank yet.
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',
},
}); | Field | What it does |
|---|---|
| inflight: true | Hold $101 while you run the check. The rail has not been called. |
| destinations | $1.00 to your revenue, $100.00 to @WorldUSD_Unit. |
| reference | This 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.
if (!checksPassed) {
await blnk.Transactions.updateStatus(withdrawal.data.transaction_id, {
status: 'void',
});
return;
} | Field | What it does |
|---|---|
| void | Releases the hold. The $101 is available again. |
If it passed, now you send. The payout provider gets $100. Same reference as the hold.
const payout = await payoutProvider.createPayout({
amount: 100,
currency: 'USD',
reference: 'wd_ach_2290',
}); | Field | What it does |
|---|---|
| amount | $100 to the rail. Your $1.00 fee already sat on revenue. |
| reference | Same 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.
await blnk.Transactions.updateStatus(withdrawal.data.transaction_id, {
status: payout.succeeded ? 'commit' : 'void',
}); | Field | What it does |
|---|---|
| commit | The rail left. The $101 stays off his checking. |
| void | The 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.
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.
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',
},
}); | Field | What it does |
|---|---|
| source / destination | Jordan out, Maya in. Required. |
| inflight: true | The $40 is reserved until the check finishes, e.g. check if both balances are in the same organization ledger. |
await blnk.Transactions.updateStatus(transfer.data.transaction_id, {
status: checksPassed ? 'commit' : 'void',
}); | Field | What it does |
|---|---|
| commit | The check passed. Maya has the $40. |
| void | The 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.