Contents

Introduction

From its outset, Modern Treasury solved the payment origination problem. We made it easy for customers to send money to recipients across a matrix of payment rails, currency and countries. 

On Tuesday, we announced our Virtual Accounts product, which solves the other side of the payment operations problem: inbound payment attribution. As customers grow and receive more payments originated by external parties, the reconciliation of those payments to their senders becomes increasingly complex and burdensome. 

Our Virtual Accounts Product

Modern Treasury’s Virtual Accounts can automatically reconcile and tag inbound payments with their sender by allocating a unique account number to which each external originating party can send payments. Customers can designate one—or more—virtual accounts per counterparty to handle received funds, which makes tracking the payments from each of those individuals unambiguous. Payments sent to a virtual account appear in the physical Internal Account that the virtual account range is mapped to (the bank owns this mapping process).

Types of Virtual Account Allocations

The banks that provide virtual accounts expose a wide range of virtual account implementations, which can be represented by:

  • A common prefix (“123456XXXX”)
  • A common suffix (“XXXX123456”)
  • A range (“12345[1111-9999]”) 
  • A bank-determined list of reserved numbers (“123450”, “123455”, “123459”, ect…) 

Our Virtual Accounts API unifies how a customer can create virtual accounts in any of these schemes by handling the account allocation logic in each scenario.

How It Works

When we were designing the Virtual Accounts API, we knew that the API would need to handle the variety of virtual account allocation schemes offered within the banking ecosystem. Customers needed the virtual account creation API to pick a free virtual account number within the range defined by their bank. Banks offering virtual accounts have various allocation types, so we needed our implementation to abstract away the complexity of those types, and identify a usable account virtual number if the customer does not specify one.

The API requires the minimum information we need to attribute a virtual account to which a counterparty can send payments. The following example shows how to create a virtual account and the response. 

In this example, let’s say that the Bank of MT provided a virtual account range 1234XXXXXX [1] attached to the Internal Account for Sesame Inc.’s ID:   "56324045-5eeb-49b0-8714-c3a1f48884a1.” 

That virtual account range would include all account numbers ranging from 1234000000 - 1234999999, giving us 100,000 possible virtual account numbers. 

POST /api/virtual_accounts
{
    name: "Sesame Inc.",
    # Sesame Inc's Counterparty Id
    counterparty_id: "24d77ddf-b13d-4c84-abc1-4d7ca671bd9a",
    # Internal Account mapped to the virtual account range.
    internal_account_id: "56324045-5eeb-49b0-8714-c3a1f48884a1" 
}
Returns 201 OK
(some fields omitted for concision)
{
    "id": "2e0296c1-1daf-4b3e-954d-fb9ec7be56f6",
    "object": "virtual_account",
    "name": "Sesame Inc.",
    "counterparty_id": "24d77ddf-b13d-4c84-abc1-4d7ca671bd9a",
    "internal_account_id": "56324045-5eeb-49b0-8714-c3a1f48884a1",
    "account_details": [
      {
       "id": "5668c0cf-972d-49c6-970f-b32591f3e8a6",
       "object": "account_detail",
       "account_number": "1234111111",
       "account_number_type": "other"
      }
     ],
    "routing_details": [
      {
       "id":"5ceb251f-0235-48a2-81cb-0c668f5ee81b",
       "object": "routing_detail",
       "payment_type": null,
       "routing_number": "121141822",
       "routing_number_type": "aba",
       "bank_name": "BANK OF MT",
       "bank_address": <address_object>,
       ...
      }
     ],
     ...
}

Payments can be sent to the Sesame Inc.’s virtual account by specifying the routing number 121141822 and account number 1234560000. In this API call, Modern Treasury found a free account number within the virtual account allocation and claimed it for Sesame Inc.’s virtual account.

How We Built It

Defining the logic to identify and claim account numbers within an allocation ended up being an interesting problem to solve. We needed this process to be performant and correct. The rest of this blog post discusses our journey to finding a solution that fit both criteria.

The Naive / Brute-Force Approach

In the first Virtual Account implementation at Modern Treasury, we wrote the following algorithm to find an unassigned account number in a prefix range. 

This simple algorithm repeatedly pulls random account numbers out of the range, determines if a collision exists for that customer's allocation, and then re-rolls if there is one. 

def virtual_account_number_taken?(account_number)
    # Returns true if the account number has been allocated
    # within the customer's virtual account range.
end

def find_free_account_number_in_range(prefix, range_width: 10)
    range_digits = range_width - prefix.length
    allowable_range = 10 ** range_digits # 10^(range_digits)

    allocated = nil
    while !allocated do
      # find a random number between 0 and the limit
      random = Random.rand(allowable_range)
      # 0-pad to fit the desired width
      random = random.to_s.rjust(range_digits, "0") 
      random_account_number = "#{prefix}#{random}"

      next if virtual_account_number_taken?(random_account_number)    
      allocated = random_account_number
    end

   allocated
end

After a moment's thought, it became clear this algorithm had a major flaw. If every possible account had been provisioned in the range, this algorithm would loop endlessly. 

Brute Force, Version 2

We tried our hand again with an implementation that capped attempts so the algorithm wouldn’t be recursive. 

We can at least complain loudly and abort if an allocation finding exceeds a certain number of attempts, but even then, if a successful and growing customer starts to allocate a significant portion of their allowed range, we might run into collision exceptions as the norm.

MAX_ATTEMPTS = 100

def virtual_account_number_taken?(account_number)
    # Returns true if the account number has been allocated
    # within the customer's virtual account range.
end

def find_free_account_number_in_range!(prefix, range_width: 10)
    range_digits = range_width - prefix.length
    allowable_range = 10 ** range_digits # 10^(range_digits)

    allocated = nil
    i = 0
    while !allocated do
      raise "Exceeded maximum iterations!" if i >= MAX_ATTEMPTS
      # find a random number between 0 and the limit
      random = Random.rand(allowable_range) 
      # 0-pad to fit the desired width
      random = random.to_s.rjust(range_digits, "0") 
      random_account_number = "#{prefix}#{random}"

      i += 1
      next if virtual_account_number_taken?(random_account_number)    

      allocated = random_account_number
    end

   allocated
end

Though this implementation will not infinitely spin, it has another problem. Since this allocation algorithm is run synchronously during the API call (without a given account number), these API calls could grow increasingly expensive as the number of expected iterations to find a free account number rises. This didn’t sit right with us, and we rethought our approach again.

Queues and Asynchronous Processing

After reviewing our initial implementation and realizing that we needed to find a sturdier and more performant virtual account number search strategy. We started by reviewing our requirements:

  1. The algorithm needed to be able to find a free account number in constant O(1) time.
  2. The solution also needed to handle the real-world constraint that an allocation might become exhausted.

We realized we could satisfy both requirements by separating the calculation of free account numbers from allocating those numbers to virtual accounts during virtual account creation. We decided to add a queue data structure for each customer's virtual account allocation range. Each queue contained a list of unallocated account numbers found by a background processing job. It turned the synchronous provisioning logic into a much simpler process.

def find_free_account_number_in_range!(internal_account)
    # find the virtual account range from the requested internal account
    virtual_account_range = internal_account.virtual_account_range 

    # get the queue structure for the virtual account range
    queue = virtual_account_range.allocation_queue

    # throw if the account range is exhausted
    random_virtual_account_number = queue.pop!

    if queue.length < minimum
      # start the asynchronous process to fill 
      # the queue with more unallocated account numbers
      refill_queue_asnchronously(queue)
    end

   random_virtual_account_number
end

The background processor would periodically scan all of the virtual account ranges for queues that had been depleted below our desired threshold. 

MINIMUM_THRESHOLD = 100

def fill_queue(virtual_account_range, queue)
    return if queue.length > MINIMUM_THRESOLD
    return if range_exhausted?(virtual_account_range)

    loop do
      random_account_number = find_free_account_number_in_range(range.prefix)
      queue.push(random_account_number)

      # Add some overhead when refilling the queues
      break if queue.length > MINIMUM_THRESOLD * 2
    end
end

def virtual_account_number_taken?(account_number)
    # Returns true if the account number has been allocated
    # within the customer's virtual account range.
end

def range_exhausted?(virtual_account_range)
    # Returns true if all possible virtual accounts within the
    # range have been allocated to virtual accounts in Modern Treasury.
end

def find_free_account_number_in_range(prefix, range_width: 10)
    range_digits = range_width - prefix.length
    allowable_range = 10 ** range_digits # 10^(range_digits)

    allocated = nil
    while !allocated do
      # find a random number between 0 and the limit
      random = Random.rand(allowable_range) 
      # 0-pad to fit the desired width
      random = random.to_s.rjust(range_digits, "0") 
      random_account_number = "#{prefix}#{random}"

      next if virtual_account_number_taken?(random_account_number)    

      allocated = random_account_number
    end

   allocated
end


Extending the Solution

By separating how Modern Treasury identifies available virtual accounts from the allocation of those virtual account numbers, we were able to abstract over the various types of virtual account products offered by banks. The Virtual Account creation API retains a simple and performant interface, while our background processors handle the complexity of enforcing uniqueness across virtual accounts within a customer's allocated range.

If you have any questions or feedback, feel free to reach out  to support@moderntreasury.com. Likewise, if you enjoy designing well-abstracted APIs, Modern Treasury is hiring software engineers.

References

1.

This type of range allocation is something we call "prefix" range allocation. In the virtual account product landscape, banks are also suffix allocations, range allocations, and reserved allocations, all of which can be handled with a similar provisioning strategy.

2.
3.
4.
5.
6.
7.
8.
9.
10.