Last active

Embed URL

HTTPS clone URL

SSH clone URL

You can clone with HTTPS or SSH.

Download Gist
View couch-transfer.rest

I haven't been able to find any 100% correct "bank account transfer with CouchDB" examples online.

So here it is (albeit with a bit of pseudo code).

All the examples get the first part — the transaction log — correct. Each time money is transferred from one account to another, a transaction is added to the transaction log:

{"from": "Alex", "to":"Sam", "amount": 100}
{"from": "David", "to": "Steve", "amount": 20}
{"from": "Work", "to": "David", "amount": 200}

And these map/reduce functions are used to determine the current balance of any account:

function(transaction) {
  emit(transaction.from, transaction.amount * -1);
  emit(transaction.to, transaction.amount);
}
function(keys, values) {
  return sum(values);
}

But this leaves the obvious question: how are errors handled? What happens if someone tries to transfer more money than is in their account?

Naively, the application's "transfer funds" function might look like this:

def naive_transfer(frm, to, amount):
    couch.insert("transactions", {"from": frm, "to": to, "amount": amount})
    if couch.get("balance?user=" + frm) < 0:
        couch.insert("transactions", {"from": frm, "to": to, "amount": amount * -1})
        raise InsufficientFunds()

But if the application crashes between inserting the transaction and checking the updated balances, this leaves the sender with a negative balance, and the recipient with money that didn't previously exist:

// Initial balances: Alex: 100, Sam: 100
{"from": "Sam", "To": "Alex", "amount": 200}
// Current balances: Alex: 300, Sam: -100

How can this be fixed?

Two pieces of information need to be added to each transaction: the time it was created (to ensure that transactions can be put in some order; specifically, a strict total ordering), and the status — whether or not it was successful.

Now there will be two views — one which returns the "available" balance, and another which returns the "pending" balance:

// map function for the "pending" balance view
function(transaction) {
    if (transaction.status == "pending" || transaction.status == "successful") {
        emit(transaction.from, transaction.amount * -1);
        emit(transaction.to, transaction.amount);
    }
}

// map function for the "available" balance view
function(transaction) {
    if (transaction.status == "successful") {
        emit(transaction.from, transaction.amount * -1);
        emit(transaction.to, transaction.amount);
    }
}

And the list of transfers might now look something like this:

{"from": "Alex", "to":"Sam", "amount": 100, "timestamp": 50, "status": "successful"}
{"from": "Sam", "To": "Alex", "amount": 200, "timestamp": 60, "status": "pending"}

Next, the application will need to have a function which can resolve transactions by checking each pending transaction in order, updating its status from "pending" to either "successful" or "rejected". In psueudo-code:

def resolve_transactions(target_timestamp):
    """ Resolves all transactions up to and including the transaction
        with timestamp ``target_timestamp``. """
    while True:
        txn = couch.get("most_recent_pending_transaction")
        if txn.timestamp > target_timestamp:
            # Stop once all of the transactions up until the one we're
            # interested in have been resolved.
            break
        if couch.get("available_balance?user=" + txn.from) >= txn.amount:
            status = "successful"
        else:
            status = "rejected"
        # Note that CouchDB will check the _rev field of the transaction
        # document, only performing the update if this transaction hasn't
        # already been updated. In this case, we don't care whether someone
        # else has updated this transaction before us, so checking the
        # return value isn't necessary.
        txn.status = status
        couch.update(txn)

And finally, the application code for correctly performing a transfer:

def transfer(frm, to, amount):
    timestamp = time.time()
    txn = couch.insert("transactions", {
        "from": frm,
        "to": to,
        "amount": amount,
        "status": "pending",
        "timestamp": timestamp,
    })
    resolve_transactions(timestamp)
    txn = couch.get_document(txn._id)
    if txn_status == "rejected":
        raise InsufficientFunds()

Notes:

  • This does not take master/master replication or CouchDB's document sync into consideration.
  • In a real system, using time() might result in collisions, so using something with a bit more entropy might be a good idea; maybe "%s-%s" %(time(), uuid()) — or similar. Including the time simply helps to keep things in a sensible order if multiple requests come in at about the same time.

In an actual bank they allow a certain amount of lash time for the dust to settle before they mark a transaction as failed or complete. For example deposited checks are shown on the balance but the amount cannot be spent until some days after to show for a clearing period.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Something went wrong with that request. Please try again.