In a previous article, I presented the Capital Controls Kata and the intent to publish a walkthrough. Here it is.

Preparation

I used Visual Studio Code as my code editor and unittest as the Python test framework.

The entire code base is available on Github.

Basic functionality

Before we dive into imposing capital controls on our banks, we need to have a set of functioning bank operations! So, here is an anonymous bank that performs the following:

  • Cash deposits
  • Cash withdrawals
  • Domestic electronic transfers
  • Electronic transfers abroad

Each operation should result in a success or fail and that should be communicated to the caller.

The bank should disallow overdrafts, i.e. attempts to withdraw or transfer more money that is in the account should result to an insufficient funds message.

There would be a domain with a Bank and an Account class. Doing Test-Driven Development or Test-First Development doesn’t mean you don’t have an architecture in mind, you’re just not married to it.

Further, successful debits or credits would create a transaction object with a date and time. Unsuccessful attempts to debit or credit an account would not create a record of the operation, but only return an “operation failed” message to the caller. If there were a need to do include records of failed attempts to interact with an account as part of the basic functionality, we would revisit that as part of the restrictions imposition.

I made the operation of transfers abroad to act the same as a cash withdrawal - these operations have funds leave the domestic banking network and domestic banks lose track of those funds forever. Why have two different operations? At the time of coding the basic functionality I went really slowly and didn’t yet know how to differentiate the operations (this isn’t a real banking application where real money does get transferred out of a domestic bank and there is no database involved here either.)

Deposit funds to an account

I started with the first test:

class BankTest(unittest.TestCase):

    def test_deposit_to_account(self):
        account = BankAccount()
        bank = Bank()

        bank.deposit_to_account(account, 100)

        self.assertEqual(100, account.balance)

Pretty straightforward, there is a bank and there is an account. Defining the methods of the problem domain, I went on to implement the bank operation:

class Bank():

    def deposit_to_account(self, account, amount):
        account._deposit(amount) 

And the account:

class Account():

    def __init__(self):
        self.balance = 0

    def _deposit(self, amount):
        self.balance += amount

This was the third simplest thing that would make the test pass. Granted, you could have simply have the balance return a constant int of 100, triangulate with more test cases and arrive at the above code and that is, in fact, the approach I took in subsequent tests. An in-between approach would be to just assign the balance to the amount in the _deposit method. It’s easy to get carried away and jump to the solution if you know what to implement. The point here is to not get too far without tests preceding your implementation and I tried to make myself cognizant of that during the process.

Withdrawal

Withdrawal came next:

def test_withdraw(self):
        account = Account(100)
        bank = Bank()

        bank.withdraw_from_account(account, 100)

        self.assertEqual(0, account.balance)

Bank class:

def withdraw_from_account(self, account, amount):
        account._withdraw(amount) 

Account class:

def _withdraw(self, amount):
        self.balance -= amount

I went slowly, but not that slowly. Instead of returning a constant from the _withdraw method, I subtracted the amount. Again, I could have taken it a step back and triangulated with more test cases.

Account methods _deposit and _withdraw are marked internal (with the leading underscore) as these should only be accessed from the same library.

One could argue that the Bank class may be starting to suffer from feature envy. At the moment it does nothing of its own but uses methods from the Account class. However, there is a strong argument to be made about class responsibilities and encapsulation - the user only interacts through their account via the bank, never directly through the account. The bank is, if you like, the gatekeeper between the ustomer and the account. If it turned out at the end of the implementation that the Bank class is redundant, the code would be refactored so Bank could be removed. You could also start from the bottom up and not have a Bank class in the first place until you need it. Remember, this is “Iteration 0” of the problem where we do not have a concept of future requirements where restrictions on capital could be asked of the development team.

Withdrawing an overdraft will result in a negative balance. Let’s fix that.

def test_withdraw_overdraft_doesnotallow(self):
        account = Account(100)
        bank = Bank()

        bank.withdraw_from_account(account, 200)

        self.assertEqual(100, account.balance)

Passing the test:

def _withdraw(self, amount):
        self.balance -= amount
        if (amount <= self.balance):
                self.balance -= amount

Both withdrawal tests are passing.

At this point, it’s time to do some clean-up. Account.balance is a public and settable property. That is more than the current tests use. Also, it seems wrong that an external entity is able to manipulate something as important as an account’s balance on a whim.

@property
def Balance(self):
        return self.__balance

def _deposit(self, amount):
        self.__balance += amount

def _withdraw(self, amount):
        if (amount <= self.__balance):
        self.__balance -= amount

We should also communicate the fact that a withdrawal operation (at first) may have failed due to insufficient funds. For that to happen, the _withdraw method can return an enum. I find this more intuitive than a boolean, even if we later on end up not expanding the scenarios for which a withdrawal may not happen.

Note that this time, in contrast with prevous examples, we are using parameterised tests to triangulate in order to arrive at the final algorithm in our production code.

@parameterized.expand([
(200,),
(400,),
(600,),
])
def test_withdraw_overdraft_results_in_error(self, withdrawal_amount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 100)

        result = bank.withdraw_from_account(account, withdrawal_amount)

        self.assertEqual(OperationResult.InsufficientFunds, result)
        self.assertEqual(account.Balance, 100)

In the Account class:

def _withdraw(self, amount):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        return OperationResult.Success

New enum in place.

class OperationResult(Enum):
    Success = 1
    InsufficientFunds = 2

The Bank class doesn’t change.

So far, so good, but our basic functionality is very basic; there really needs to be a concept of a record that a transaction has taken place with every successful deposit, withdrawal and transfer.

Transaction

A transaction will have to be created with each deposit. That transaction should hold the date and time that it took place. The Account class should have a concept of transactions. Additionally, it seems wrong that an account can be initialised with a balance out of nowhere; every amount that is in the account should come in with a record of a transaction having taken place.

@parameterized.expand([
       (100,),
       (200,),
       (300,),
    ])
    def test_deposit_to_account_creates_transaction(self, amount):
        account = Account()
        bank = Bank()

        operationResult = bank.deposit_to_account(account, amount)

        self.assertEqual(OperationResult.Success, operationResult)

        transaction = self.__get_first_transaction(account)
        self.assertEqual(TransactionType.Credit, transaction.Type)
        self.assertEqual(amount, transaction.Amount)

    def __get_first_transaction(self, account):
        for transaction in account.Transactions:
            break
        return transaction
class Transaction():

    def __init__(self, type, amount):
        self.Type = type
        self.Amount = amount
class Account():
    
    def __init__(self):
        self.__balance = 0
        self.Transactions = set()
        
    def _deposit(self, amount):
        self.__balance += amount
        self.Transactions.add(Transaction(TransactionType.Credit, amount))
        return OperationResult.Success

        .
        .
        .
class TransactionType(Enum):
    Credit = 1

Let’s add more properties to the transaction object apart from just its type. We will need an amount and the date and time it took place.

@parameterized.expand([
       (100,),
       (200,),
       (300,),
    ])
    def test_deposit_to_account_creates_transaction(self, amount):
        class DateTimeMock(datetime.datetime):
            @classmethod
            def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)

        daatetimemock = DateTimeMock

        account = Account(daatetimemock)
        bank = Bank()
                
        operationResult = bank.deposit_to_account(account, amount)

        self.assertEqual(OperationResult.Success, operationResult)

        transaction = self.__get_first_transaction(account)
        self.assertEqual(TransactionType.Credit, transaction.Type)
        self.assertEqual(amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)

Expanding the previous test, we’re creating a mock that will give us a date and time we can assert to. We are also asserting an amount.

class Transaction():

    def __init__(self, type, amount, datetime):
        self.Type = type
        self.Amount = amount
        self.DateTime = datetime
class Account():

    def __init__(self, datetime = datetime.datetime):
        self.__balance = 0
        self.Transactions = set()
        self.datetime = datetime

    def _deposit(self, amount):
        self.__balance += amount
        self.Transactions.add(Transaction(TransactionType.Credit, amount, self.datetime.now()))
        .
        .
        .

For withdrawals:

 @parameterized.expand([
       (100,),
       (50,),
       (30,),
    ])
    def test_withdraw_creates_transaction(self, withdrawal_amount):
        class DateTimeMock(datetime.datetime):
            @classmethod
            def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)        
        datetimemock = DateTimeMock

        account = Account(datetimemock)

        bank = Bank()
        bank.deposit_to_account(account, 100)

        bank.withdraw_from_account(account, withdrawal_amount)
        transaction = self.__get_transaction(account, lambda t: t.Type == TransactionType.Debit)
        self.assertNotEqual(None, transaction)
        self.assertEqual(withdrawal_amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)

We want to assert there is a transaction for this withdrawal and it’s been marked as a debit

Account class:

def _withdraw(self, amount):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.Transactions.add(Transaction(TransactionType.Debit, amount, self.datetime.now()))
        return OperationResult.Success

Transaction class:

class TransactionType(Enum):
    Credit = 1
    Debit = 2

Any potential for refactoring? Sure there is.

  • Passing the date/time onto a transaction shouldn’t be a responsibility of the Account class, in my opinion, but that of the Bank class. The Bank is the “driver” behind these operations, the account is a “receiver”. This is also shown by the way the tests are structured. We ever test the Account class directly, we test it through the Bank class, which is the interface between the user and their accounts.
  • The Transactions can (and should) be encapsulated into a private member with a getter.
  • Same for the Transaction class’s members.

Let’s begin.

Refactoring 1: Moving the date/time provider

@parameterized.expand([
       (100,),
       (200,),
       (300,),
    ])
    def test_deposit_to_account_creates_transaction(self, amount):
        class DateTimeMock(datetime.datetime):
            @classmethod
            def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)

        datetimemock = DateTimeMock

        account = Account()
        bank = Bank(datetimemock)
                
        operationResult = bank.deposit_to_account(account, amount)

        self.assertEqual(OperationResult.Success, operationResult)

        transaction = self.__get_first_transaction(account)
        self.assertEqual(TransactionType.Credit, transaction.Type)
        self.assertEqual(amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)

@parameterized.expand([
       (100,),
       (50,),
       (30,),
    ])
    def test_withdraw_creates_transaction(self, withdrawal_amount):
        class DateTimeMock(datetime.datetime):
            @classmethod
            def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)        
        datetimemock = DateTimeMock

        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 100)

        bank.withdraw_from_account(account, withdrawal_amount)
        transaction = self.__get_transaction(account, lambda t: t.Type == TransactionType.Debit)
        self.assertNotEqual(None, transaction)
        self.assertEqual(withdrawal_amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)
import datetime

class Bank():

    def __init__(self, datetimeprovider = datetime.datetime):
        self.__datetimeprovider = datetimeprovider

    def deposit_to_account(self, account, amount):
        return account._deposit(amount, self.__datetimeprovider.now())

    def withdraw_from_account(self, account, amount):
        return account._withdraw(amount, self.__datetimeprovider.now())
class Account():
    
    def __init__(self):
        self.__balance = 0
        self.Transactions = set()

    @property
    def balance(self):
        return self.__balance
        
    def _deposit(self, amount, datetime):
        self.__balance += amount
        self.Transactions.add(Transaction(TransactionType.Credit, amount, datetime))
        return OperationResult.Success

    def _withdraw(self, amount, datetime):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.Transactions.add(Transaction(TransactionType.Debit, amount, datetime))
        return OperationResult.Success

Refactoring 2: Transactions in Account made private with a getter

class Account():
    
    def __init__(self):
        self.__balance = 0
        self.__transactions = set()

    @property
    def Transactions(self):
        return self.__transactions

    def _deposit(self, amount, datetime):
        self.__balance += amount
        self.__transactions.add(Transaction(TransactionType.Credit, amount, datetime))
        return OperationResult.Success

    def _withdraw(self, amount, datetime):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.__transactions.add(Transaction(TransactionType.Debit, amount, datetime))
        return OperationResult.Success

        .
        .
        .

Refactoring 3: Transaction member encapsulation

class Transaction():
        def __init__(self, type, amount, datetime):
                self.__type = type
                self.__amount = amount
                self.__dateTime = datetime
        @property
                def Type(self):
                return self.__type
        @property
                def Amount(self):
                return self.__amount
        @property
                def DateTime(self):
                return self.__dateTime

Electronic transfer (domestic)

Next up is domestic electronic transfer of funds. The below test sets up balances on two accounts and then transfers funds from one to the other.

def test_eletronic_transfer(self):
        account_from = Account()
        account_to = Account()
        bank = Bank()

        bank.deposit_to_account(account_from, 100)
        bank.deposit_to_account(account_to, 50)

        result = bank.transfer(account_from, account_to, 50)

        self.assertEqual(50, account_from.Balance)
        self.assertEqual(100, account_to.Balance)
        self.assertEqual(OperationResult.Success, result)

If you have to explain a unit test it’s probably a bad one.</sub>

In Bank:

def transfer(self, account_from, account_to, amount):
        date = self.__datetimeprovider.now()
        account_from._withdraw(amount, date)
        account_to._deposit(amount, date)
        return OperationResult.Success

In this implementation, if a account_to._deposit(amount, date) operation fails for whatsoever reason, the funds would be lost from the account_from account. For the purposes of this exercise, we will not be dealing with the transactionality of operations.

It does pass the test but a transfer isn’t really a cash withdrawal and a deposit. We will have to rethink this functionality. But first, let’s implement the alectronic transfer abroad.

Eletronic transfer (abroad)

We are starting with a test to update an account’s balance. We also want to keep the API of the Bank and the Account consistent with the other operations, so we are returning an OperationResult.

@parameterized.expand([
       (100, 50),
       (200, 100),
       (300, 100),
    ])
    def test_transfer_abroad_removes_from_balance(self, initialbalance, withdrawalamount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, initialbalance)

        result = bank.transfer_abroad(account, withdrawalamount)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(initialbalance - withdrawalamount, account.Balance)

Bank:

def transfer_abroad(self, account, amount):
        return account._transfer_abroad(amount, self.__datetimeprovider.now())

Account:

def _transfer_abroad(self, amount, datetime):
        self.__balance -= amount
        return OperationResult.Success

Next up is to guard against an insufficient balance.

@parameterized.expand([
        (200,),
        (400,),
        (600,),
])
def test__transfer_abroad_insufficient_sender_funds(self, withdrawalamount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 100)

        result = bank.transfer_abroad(account, withdrawalamount)

        self.assertEqual(OperationResult.InsufficientFunds, result)
        self.assertEqual(account.Balance, 100)

Account:

def _transfer_abroad(self, amount, datetime):
        if (self.__balance < amount):
                return OperationResult.InsufficientFunds
        self.__balance -= amount
        return OperationResult.Success

And finally, the transaction:

@parameterized.expand([
       (100,),
       (50,),
       (30,),
    ])
    def test__transfer_abroad_creates_transaction(self, withdrawal_amount):
        class DateTimeMock(datetime.datetime):
            @classmethod
            def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)        
        datetimemock = DateTimeMock

        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 100)

        bank.transfer_abroad(account, withdrawal_amount)
        transaction = self.__get_transaction(account, lambda t: t.Type == TransactionType.Debit)
        self.assertNotEqual(None, transaction)
        self.assertEqual(withdrawal_amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)

In Account:

    def _transfer_abroad(self, amount, datetime):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.__transactions.add(Transaction(TransactionType.Debit, amount, datetime))
        return OperationResult.Success

Marking debit types

As mentioned earlier, for the purposes of this exercise we will only be simulating the difference between the various ways an account can be debited. In this instance we can mark debit as such. We have three types of debit, one is for cash withdrawals, one for domestic bank transfers and one for transfers abroad. Let’s implement those.

@parameterized.expand([
       (100,),
       (50,),
       (30,),
    ])
    def test_withdraw_creates_transaction(self, withdrawal_amount):
        class DateTimeMock(datetime.datetime):
            @classmethod
            def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)        
        datetimemock = DateTimeMock

        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 100)

        bank.withdraw_from_account(account, withdrawal_amount)
        transaction = self.__get_transaction(account, lambda t: t.Type == TransactionType.Debit)
        self.assertNotEqual(None, transaction)
        self.assertEqual(withdrawal_amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)
        self.assertEqual(DebitType.CashWithdrawal, transaction.DebitType)

The last line will assert that with a cash withdrawal, the transaction that is created will be marked as a Cash Withdrawal one.

In Account:

    def _withdraw(self, amount, datetime):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.__transactions.add(Transaction(TransactionType.Debit, amount, datetime, DebitType.CashWithdrawal))
        return OperationResult.Success

And in Transaction:

class Transaction():

    def __init__(self, type, amount, datetime, debitType = None):
        self.__type = type
        self.__amount = amount
        self.__dateTime = datetime
        self.__debitType = debitType

    @property
    def Type(self):
        return self.__type

    @property
    def Amount(self):
        return self.__amount

    @property
    def DateTime(self):
        return self.__dateTime

    @property
    def DebitType(self):
        return self.__debitType

New enum, DebitType:

class DebitType(Enum):
    CashWithdrawal = 1

We follow the same logic to mark domestic transfers and transfers abroad. I did remove the cash withdrawal and deposit logic previously used to simulate a bank transfer and now I have a different operation in place that creates a transaction marked as a DomesticElectronicTransfer. I didn’t think that for this exercise we would need a way to distinguish between credit transactions so I didn’t implement one. The funds in a domestic electroncic transfer are still credited to the beneficiary with the generic Account._deposit method.

If a transaction is a debit transaction (it’s TransactionType property is Debit) then its DebitType property must not be None. This is not enforced by unit tests, this is an internal implementation of how Account creates transactions.

Restriction 1 - ban all transfers abroad and enforce a $60 limit on daily cash withdrawals

I must admit I only picked up steam once I reached that point. I thought this is going to be the real challenge of this exercise, how do we cleanly enforce restrictions in our bank operations?

As the business logic changes, the tests will change, so I started there. The assertions in place will only be valid if the withdrawal amount is $60 or less. Transfers abroad aren’t allowed.

Transfers Abroad

def test_transfer_abroad_not_allowed(self):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 100)

        result = bank.transfer_abroad(account, 10)

        self.assertEqual(OperationResult.NotAllowed, result)

In Bank:

def transfer_abroad(self, account, amount):
        return OperationResult.NotAllowed

Simple as that. The account’s functionality remains untouched. The “guardian” to the account, which is the bank, controls what kind of access is given to an account.

Here I’ve introduced another named value in the OperationResult enum. NotAllowed means that although the funds are there to be debited from the account and the transfer can otherwise happen, there are restrictions in place that disallow this.

What’s happened to the other Transfer Abroad tests we had? The test that assert insufficient funds is now failing

@parameterized.expand([
        (200,),
        (400,),
        (600,),
])
def test__transfer_abroad_insufficient_sender_funds(self, withdrawalamount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 100)

        result = bank.transfer_abroad(account, withdrawalamount)

        self.assertEqual(OperationResult.InsufficientFunds, result)
        self.assertEqual(account.Balance, 100)

Line self.assertEqual(OperationResult.InsufficientFunds, result) is now failing as the OperationResult we get back is InsufficientFunds. Also, this test case no longer applies as we simply disallow any and all transfers abroad. Therefore this and all other test that have driven the Transfer Abroad functionality except for the one we just added, no longer assert current business rules and are deleted.

Cash withdrawals

New test with three test cases for triangulation:

@parameterized.expand([
       (61,),
       (62,),
       (70,),
    ])
    def test_withdrawal_of_over_60_dollars_not_allowed(self, withdrawal_amount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 100)

        result = bank.withdraw_from_account(account, withdrawal_amount)

        self.assertEqual(OperationResult.NotAllowed, result)
        self.assertEqual(100, account.Balance)

This obviously fails until we implement the desired functionality

In Bank:

def withdraw_from_account(self, account, amount):
        if (amount > 60):
                return OperationResult.NotAllowed

        return account._withdraw(amount, self.__datetimeprovider.now())

Some test cases for test_withdraw_removes_from_balance,test_withdraw_overdraft_results_in_error and test_withdraw_creates_transaction are failing because thye are out of date so we update the test cases for the withdrawal amounts to conform to the new business rules.

@parameterized.expand([
        (10, 5),
        (20, 10),
        (30, 10),
])
def test_withdraw_removes_from_balance(self, initialbalance, withdrawalamount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, initialbalance)

        result = bank.withdraw_from_account(account, withdrawalamount)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(initialbalance - withdrawalamount, account.Balance)

@parameterized.expand([
        (20,),
        (40,),
        (60,),
])
def test_withdraw_overdraft_results_in_error(self, withdrawalamount):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 10)

        result = bank.withdraw_from_account(account, withdrawalamount)

        self.assertEqual(OperationResult.InsufficientFunds, result)
        self.assertEqual(account.Balance, 10)
    
@parameterized.expand([
        (50,),
        (40,),
        (30,),
])
def test_withdraw_creates_transaction(self, withdrawal_amount):
        class DateTimeMock(datetime.datetime):
                @classmethod
                def now(cls):
                return cls(2020, 1, 1, 15, 45, 0)        
        datetimemock = DateTimeMock

        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 100)

        bank.withdraw_from_account(account, withdrawal_amount)
        transaction = self.__get_transaction(account, lambda t: t.Type == TransactionType.Debit)
        self.assertNotEqual(None, transaction)
        self.assertEqual(withdrawal_amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)

At this point I decided to swap the inner classes for mocking the date and time with Python’s unittest.mock library to make tests more readable.

More than one cash withdrawal in a day

    def test_withdrawal_over_daily_amount_in_multiple_transactions_not_allowed_three_transactions(self):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 100)

        bank.withdraw_from_account(account, 20)
        bank.withdraw_from_account(account, 20)

        result = bank.withdraw_from_account(account, 21)

        self.assertEqual(OperationResult.NotAllowed, result)
        self.assertEqual(60, account.Balance)

In Bank:

    def withdraw_from_account(self, account, amount):
        if (amount > Bank.__max_daily_limit):
            return OperationResult.NotAllowed

        amount_already_drawn = 0

        for trn in account.Transactions:
            if trn.Type == TransactionType.Debit:
                amount_already_drawn += trn.Amount

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            return OperationResult.NotAllowed
            
        return account._withdraw(amount, self.__datetimeprovider.now())

Note the class variable __max_daily_limit. This is a constant set to 60. We have done away with the magic integer of 60 and have given it context.

This code will not renew the daily allowance of an account the next day. Let’s write a test that asserts that we will be able to.

def test_withdrawal_over_daily_amount_in_multiple_transactions_across_two_days_is_allowed(self):
        datetimemock = Mock()
        datetimemock.now.return_value = datetime.datetime(2020, 1, 1, 15, 45, 0)

        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 100)

        bank.withdraw_from_account(account, 60)

        datetimemock.now.return_value = datetime.datetime(2020, 1, 2, 15, 46, 0)
        result = bank.withdraw_from_account(account, 10)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(30, account.Balance)

And in Bank:

def withdraw_from_account(self, account, amount):
        if (amount > Bank.__max_daily_limit):
            return OperationResult.NotAllowed

        amount_already_drawn = 0

        for trn in account.Transactions:
            if trn.Type == TransactionType.Debit and trn.DateTime.date() == self.__datetimeprovider.now().date():
                amount_already_drawn += trn.Amount

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            return OperationResult.NotAllowed
            
        return account._withdraw(amount, self.__datetimeprovider.now())

Here, we’re checking what has already been withdrawn on the day before we decide if the operation is allowed.

Removing the first two lines of withdraw_from_account does not affect the tests, therefore the lines were redundant.

Restriction 2: Roll over missed cash withdrawals for up to a week.

    def test_withdrawal_if_monday_missed_withdraw_twice_as_much_on_tuesday(self):        
        account = Account()
        datetimemock = Mock()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 200)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 12, 15, 45, 0)
        result = bank.withdraw_from_account(account, 120)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(80, account.Balance)

Small steps here. May 12, 2020 is a Tuesday and we want to take out Tuesday’s allowance as well as Monday’s.

In Bank:

def withdraw_from_account(self, account, amount):           
        amount_already_drawn = 0

        for trn in account.Transactions:
            if trn.Type == TransactionType.Debit and trn.DateTime.date() == self.__datetimeprovider.now().date():
                amount_already_drawn += trn.Amount

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            diff = amount_already_drawn + amount - Bank.__max_daily_limit
            money_drawn_previous_day = 0
            for trn in account.Transactions:
                if trn.Type == TransactionType.Debit and trn.DateTime.date() == self.__datetimeprovider.now().date() - timedelta(days = 1):
                    money_drawn_previous_day += trn.Amount

            if money_drawn_previous_day + diff <= Bank.__max_daily_limit:
                return account._withdraw(amount, self.__datetimeprovider.now())

            return OperationResult.NotAllowed
            
        return account._withdraw(amount, self.__datetimeprovider.now())

There’s a lot going on here. Is there any clean up we can do?

For one thing, some of the account querying can be moved where it’s better suited; the Account class.

Account

def _get_withdrawn_amount_on_date(self, date):     
        amount = 0   
        for trn in self.Transactions:
            if trn.Type == TransactionType.Debit and trn.DateTime.date() == date.date():
                amount += trn.Amount

        return amount

Bank

 def withdraw_from_account(self, account, amount):           
        amount_already_drawn = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now())

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            diff = amount_already_drawn + amount - Bank.__max_daily_limit
            money_drawn_previous_day = 0
            for trn in account.Transactions:
                if trn.Type == TransactionType.Debit and trn.DateTime.date() == self.__datetimeprovider.now().date() - timedelta(days = 1):
                    money_drawn_previous_day += trn.Amount

            if money_drawn_previous_day + diff > Bank.__max_daily_limit:
                return OperationResult.NotAllowed            
            
        return account._withdraw(amount, self.__datetimeprovider.now())

It’s already starting to look better. Proceeding further:

Bank

 def withdraw_from_account(self, account, amount):           
        amount_already_drawn = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now())

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            diff = amount_already_drawn + amount - Bank.__max_daily_limit
            money_drawn_previous_day = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=1))

            if money_drawn_previous_day + diff > Bank.__max_daily_limit:
                return OperationResult.NotAllowed            
            
        return account._withdraw(amount, self.__datetimeprovider.now())

The account has now the additional responsibility of querying its transactions to return the amount that was withdrawn on a day and the bank makes a query when it needs it.

Only we can do better still. Whether or not to allow a withdrawal can move to its own method.

def withdraw_from_account(self, account, amount):       
        if not self.__can_withdraw(account, amount):  
            return OperationResult.NotAllowed            
            
        return account._withdraw(amount, self.__datetimeprovider.now())

def __can_withdraw(self, account, amount):
        amount_already_drawn = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now())

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            diff = amount_already_drawn + amount - Bank.__max_daily_limit
            money_drawn_previous_day = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=1))
 
            return money_drawn_previous_day + diff <= Bank.__max_daily_limit
        
        return True

That will any missed allowance to carry over by a day. What about the rest of the week?

Let’s write a test to assert that on Saturday we will be able to withdraw any cash we didn’t withdrawn since Monday.

    def test_withdrawal_if_less_than_the_limit_was_drawn_throughout_the_week_allow_withdrawal_up_to_today(self):
        account = Account()
        datetimemock = Mock()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 2000)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 12, 12, 0, 0)   # Tuesday   
        bank.withdraw_from_account(account, 60)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 16, 12, 0, 0)   # Saturday   
        result = bank.withdraw_from_account(account, 250)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(1690, account.Balance)

Passing the test in Bank:

def __can_withdraw(self, account, amount):
        amount_already_drawn = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now())

        if amount_already_drawn + amount > Bank.__max_daily_limit:
            diff = amount_already_drawn + amount - Bank.__max_daily_limit
            money_drawn_previous_day = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=1))
            money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=2))
            money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=3))
            money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=4))
            money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=5))

            return money_drawn_previous_day + diff <= Bank.__max_daily_limit * 5
        
        return True

That will take us back five days to calculate withdrawn amounts. Philosophical analysis: this is any five days back, so if the current day was a Tuesday this implementation would calculate all withdrawn amounts back to Thursday next week so we’re getting more functionality than the unit test requires. This all comes down to “what is the smallest piece of code that will pass a test”. The above segment will pass the test. Placing guard clauses to only stop at the Monday of the current week (and not cover potential cases not covered by the newest unit test) would require additional code, so I didn’t do it as it seemed like I would overengineer the solution. However, there will be other unit tests that prevent the algorithm going back too far in the past.

After test_withdrawal_if_less_than_the_limit_was_drawn_throughout_the_week_allow_withdrawal_up_to_today, passed, other tests failed. More specifically:

test_withdrawal_of_over_daily_limit_not_allowed test_withdrawal_over_daily_limit_in_multiple_transactions_not_allowed_two_transactions test_withdrawal_over_daily_limit_in_multiple_transactions_not_allowed_three_transactions

were failing as the implementaiton in Bank would go back five days in the past and wouldn’t stop on Monday. These tests won’t pass until we change the logic of the Bank class to stop on Monday of the current week and put new test cases on them.

Next up, we need to do something about this:

money_drawn_previous_day = account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=1))
money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=2))
money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=3))
money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=4))
money_drawn_previous_day += account._get_withdrawn_amount_on_date(self.__datetimeprovider.now() - timedelta(days=5))

Sometimes you get to a point where writing a more generic algorithm may be simpler than passing this above case but you need to be careful how far away from your tests you’re stepping. It seems like the time has come to implement the functionality to only go far back until the Monday for the current week.

Enriching a test in our test suite with test cases:

@parameterized.expand([
       (11, 60, 16, 250, OperationResult.Success), #first day: Monday, second day:Saturday
       (12, 60, 17, 360, OperationResult.Success), #first day: Tuesday, second day:Sunday
       (14, 60, 17, 361, OperationResult.NotAllowed), #first day: Thursday, second day:Sunday
       (11, 60, 12, 61, OperationResult.NotAllowed), #first day: Monday, second day:Tuesday
    ])
    def test_withdrawal_if_less_than_the_limit_was_drawn_throughout_the_week_allow_withdrawal_up_to_today(self, day_of_month_first_trn, first_withdrawal_amount, day_of_month_second_trn, second_withdrawal_amount, operation_result):
        account = Account()
        datetimemock = Mock()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 2000)

        datetimemock.now.return_value = datetime.datetime(2020, 5, day_of_month_first_trn, 12, 0, 0)  
        bank.withdraw_from_account(account, first_withdrawal_amount)

        datetimemock.now.return_value = datetime.datetime(2020, 5, day_of_month_second_trn, 12, 0, 0) 
        result = bank.withdraw_from_account(account, second_withdrawal_amount)

        self.assertEqual(operation_result, result)

In Bank:

def __can_withdraw(self, account, amount):
        now = self.__datetimeprovider.now()
        amount_already_drawn = account._get_withdrawn_amount_on_date(now)

        if amount_already_drawn + amount > Bank.__max_daily_limit: 
            day_of_week_index = now.weekday()
            money_withdrawn_this_week = 0
            if (day_of_week_index > 0):
                for i in range(1, day_of_week_index + 1):
                    money_withdrawn_this_week += account._get_withdrawn_amount_on_date(now - timedelta(days = i))
          
            return money_withdrawn_this_week + amount <= Bank.__max_daily_limit * (day_of_week_index + 1)
        
        return True

In Python, datetime.weekday() is a zero-based index of the day of the week, so it ranges from 0 (Monday) to 6 (Sunday). This passes the test but what can we do to make it better?

For one thing, removing line amount_already_drawn = account._get_withdrawn_amount_on_date(now) and the conditional if amount_already_drawn + amount > Bank.__max_daily_limit: will still pass the test and can be removed. One could argue that without the if clause, the method will enumerate cash withdrawals for the past few days even if withdrawal eligibility is ruled out from day one, but our aim here is maintainability before performance. We can make it fast later if we have to. For now, let’s simplify our algorithm.

def __can_withdraw(self, account, amount):
        now = self.__datetimeprovider.now()
        day_of_week_index = now.weekday()
        money_withdrawn_this_week = 0
        if (day_of_week_index > 0):
                for i in range(0, day_of_week_index + 1):
                money_withdrawn_this_week += account._get_withdrawn_amount_on_date(now - timedelta(days = i))

        return money_withdrawn_this_week + amount <= Bank.__max_daily_limit * (day_of_week_index + 1)  

That’s good, except we can do better. Calculation of the money withdrawn in the current week can be moved to Account as it’s a query more appropriate for an account.

Bank:

def __can_withdraw(self, account, amount): 
        now = self.__datetimeprovider.now()
        day_of_week_index = now.weekday()     
        money_withdrawn_this_week = account._get_withdrawn_amount_this_week_so_far_for_date(now)
        
        return money_withdrawn_this_week + amount <= Bank.__max_daily_limit * (day_of_week_index + 1)    

Account gets a new internal method:

def _get_withdrawn_amount_this_week_so_far_for_date(self, date):
        day_of_week_index = date.weekday()
        money_withdrawn = 0
        if (day_of_week_index > 0):
            for i in range(0, day_of_week_index + 1):
                money_withdrawn += self.__get_withdrawn_amount_on_date(date - timedelta(days = i))

        return money_withdrawn

and a new private method:

def __get_withdrawn_amount_on_date(self, date):     
        amount = 0   
        for trn in self.Transactions:
            if trn.Type == TransactionType.Debit and trn.DateTime.date() == date.date():
                amount += trn.Amount
        
        return amount

Test test_withdrawal_if_monday_missed_withdraw_twice_as_much_on_tuesday is now redundant and removed.

Test test_withdrawal_if_less_than_the_limit_was_drawn_throughout_the_week_allow_withdrawal_up_to_today can have additional test cases to check what happens across weeks; if we attempt to draw out $121 on a Tuesday, that must be disallowed as up to $120 are permitted on any Tuesday.

 @parameterized.expand([
       (16, 60, 20, 181, OperationResult.NotAllowed), #first day: Saturday, second day: Wednesday next week
    ])
    def test_withdrawal_if_less_than_the_limit_was_drawn_throughout_the_week_allow_withdrawal_up_to_today(self, day_of_month_first_trn, first_withdrawal_amount, day_of_month_second_trn, second_withdrawal_amount, operation_result):

That passes without more production code, which means our previous implementation also covers that case.

Structure so far: account has methods to get money drawn out this day (private) and week (internal)

Restriction 3: Trasfers abroad are permitted for up to $500

In this iteration we allow transfers abroad again but there is an upper limit.

@parameterized.expand([
        (489, OperationResult.Success),
        (499, OperationResult.Success),
        (500, OperationResult.Success),
        (500.50, OperationResult.NotAllowed),
        (501, OperationResult.NotAllowed),
        (502, OperationResult.NotAllowed)
])
def test_transfer_abroad_up_to_weekly_limit_500(self, amount, operation_result):
        account = Account()
        bank = Bank()
        bank.deposit_to_account(account, 1000)

        result = bank.transfer_abroad(account, amount)

        self.assertEqual(operation_result, result)

which leads us to this implementation:


def transfer_abroad(self, account, amount):
        if amount <= 500:
                return account._transfer_abroad(amount, self.__datetimeprovider.now())

        return OperationResult.NotAllowed

with a bit of refactoring to give context to a “magic” number:

__max_weekly_bank_transfer_abroad_limit = 500

def transfer_abroad(self, account, amount):
        if amount <= Bank.__max_weekly_bank_transfer_abroad_limit:
                return account._transfer_abroad(amount, self.__datetimeprovider.now())

        return OperationResult.NotAllowed

Mark electronic transfers

At this time there is no way of knowing whether a transaction is a transfer abroad one. We need to mark this. That could have been done as part of the basic functionality, but we can always apply it to new transactions going forward as it will be adequate to implement this new waiving of restrictions. We’re doing this for domestic transfers too.

class DebitType(Enum):
    CashWithdrawal = 1,
    ElectronicTransferDomestic = 2,
    ElectronicTransferAbroad = 3
@parameterized.expand([
       (100,),
       (50,),
       (30,),
    ])
    def test__transfer_abroad_creates_transaction(self, withdrawal_amount):
        datetimemock = Mock()
        datetimemock.now.return_value = datetime.datetime(2020, 5, 11, 12, 0, 0)

        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 100)

        bank.transfer_abroad(account, withdrawal_amount)
        transaction = self.__get_transaction(account, lambda t: t.Type == TransactionType.Debit)
        self.assertNotEqual(None, transaction)
        self.assertEqual(withdrawal_amount, transaction.Amount)
        self.assertEqual(datetime.datetime(2020, 1, 1, 15, 45, 0), transaction.DateTime)
        self.assertEqual(DebitType.ElectronicTransferAbroad, transaction.DebitType)

And in Account:

def _transfer_domestic(self, amount, datetime):
        if (self.__balance < amount):
            return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.__transactions.add(Transaction(TransactionType.Debit, amount, datetime, DebitType.ElectronicTransferDomestic))
        return OperationResult.Success


def _transfer_abroad(self, amount, datetime):
        if (self.__balance < amount):
                return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.__transactions.add(Transaction(TransactionType.Debit, amount, datetime, DebitType.ElectronicTransferAbroad))
        return OperationResult.Success

Multiple electronic transfers abroad to reach or surpass weekly limit

 @parameterized.expand([
       (250, 250, OperationResult.Success), 
       (100, 100, OperationResult.Success), 
       (250, 251, OperationResult.NotAllowed),
       (499, 2, OperationResult.NotAllowed)
    ])
    def test_transfer_abroad_up_to_weekly_limit_two_transactions(self, first_withdrawal_amount, second_withdrawal_amount, second_trn_operation_result):
        datetimemock = Mock()
        datetimemock.now.return_value = datetime.datetime(2020, 5, 11, 12, 0, 0)
        account = Account()
        bank = Bank(datetimemock)
        bank.deposit_to_account(account, 2000)     

        bank.transfer_abroad(account, first_withdrawal_amount)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 15, 12, 0, 0)

        result = bank.transfer_abroad(account, second_withdrawal_amount)

        self.assertEqual(second_trn_operation_result, result)

A parameterised unit test like this will allow us to go past the initial implementation which would allow transfers abroad over the weekly limit if multiple of those were had.

Bank

def transfer_abroad(self, account, amount):
        if self.__can_transfer_abroad(account, amount):  
            return account._transfer_abroad(amount, self.__datetimeprovider.now())

        return OperationResult.NotAllowed 

def __can_transfer_abroad(self, account, amount): 
        now = self.__datetimeprovider.now() 
        money_withdrawn_this_week = account._get_withdrawn_amount_this_week_so_far_for_date(now)

        return money_withdrawn_this_week + amount <= Bank.__max_weekly_bank_transfer_abroad_limit   

Testing cash withdrawals (first) and transfers abroad (second)

This is going to be interesting to see because in the __can_transfer_abroad method of the Bank class, before the decision whether to allow a transfer abroad is made, we’re checking the total amount that has been debited this week including cash withdrawals, not just transfers abroad. Account._get_withdrawn_amount_this_week_so_far_for_date will return a greater amount if also cash withdrawals were made during the week and legitimate below-limit amounts to be transferred abroad won’t check out. Let’s distinguish the two types of debit.

def test_withdraw_to_limit_and_transfer_abroad_to_limit_in_same_week(self):
        account = Account() 
        bank = Bank()
        bank.deposit_to_account(account, 2000)     
        result_withdraw = bank.withdraw_from_account(account, 420)
        result_transfer_abroad = bank.transfer_abroad(account, 500)

        self.assertEqual(OperationResult.Success, result_withdraw)        
        self.assertEqual(OperationResult.Success, result_transfer_abroad)

It was a bad idea to not mock the date here. I wrote this test on a Sunday. The next day this test failed when asserting that the cash withdrawal is successful. Why? (Hint: it’s in the business rules).

In the Bank class:

def __can_transfer_abroad(self, account, amount): 
        now = self.__datetimeprovider.now() 
        
        money_transfered_abroad_this_week = account._get_amount_transfered_abroad_this_week_so_far_for_date(now)

        return money_transfered_abroad_this_week + amount <= Bank.__max_weekly_bank_transfer_abroad_limit 

Bank is using the yet-unwritten Account’s method to get all the money that’s been transferred abroad this week.

Account

def _get_amount_transfered_abroad_this_week_so_far_for_date(self, date):
        day_of_week_index = date.weekday()
        money_withdrawn = 0
        if (day_of_week_index > 0):
            for i in range(0, day_of_week_index + 1):
                money_withdrawn += self._get_amount_transfered_abroad_for_date(date - timedelta(days = i))

        return money_withdrawn

def _get_amount_transfered_abroad_for_date(self, date):
        amount = 0   
        for trn in self.Transactions:
                and trn.DebitType == DebitType.ElectronicTransferAbroad \
                and trn.DateTime.date() == date.date():
                amount += trn.Amount

        return amount

Testing transfers abroad (second) and cash withdrawals (first)

Now, we’ll transfer money abroad and then withdraw cash. Bear in mind that Account’s __get_withdrawn_amount_on_date still looks like this:

def __get_withdrawn_amount_on_date(self, date):     
        amount = 0   
        for trn in self.Transactions:
            if trn.Type == TransactionType.Debit and trn.DateTime.date() == date.date():            
                amount += trn.Amount

        return amount

The amount for all debits on a given date will be returned.

Starting with a test:

def test_transfer_abroad_to_limit_and_withdraw_to_limit_and_in_same_week(self):
        account = Account() 
        bank = Bank()
        bank.deposit_to_account(account, 2000)    
        result_transfer_abroad = bank.transfer_abroad(account, 500) 
        result_withdraw = bank.withdraw_from_account(account, 420)

        self.assertEqual(OperationResult.Success, result_transfer_abroad)
        self.assertEqual(OperationResult.Success, result_withdraw)        

To pass this, we revisit __get_withdrawn_amount_on_date to add the cash withdrawal restriction

def __get_withdrawn_amount_on_date(self, date):     
        amount = 0   
        for trn in self.Transactions:            
            if trn.DebitType == DebitType.CashWithdrawal \
                and trn.DateTime.date() == date.date():
                amount += trn.Amount

        return amount

It’s time for some refactoring.

Refactor 1: Factor out debit transaction queries

There are two private methods in Account that largely do the same thing; one gets the total amount withdrawn as cash on a date and the other gets the total amount transfered abroad. We can extract a common method for the two.

 def _get_withdrawn_amount_this_week_so_far_for_date(self, date):
        return self._get_debited_amount_this_week_so_far_for_date(date, DebitType.CashWithdrawal)

def _get_amount_transfered_abroad_this_week_so_far_for_date(self, date):
        return self._get_debited_amount_this_week_so_far_for_date(date, DebitType.ElectronicTransferAbroad)

def __get_debited_amount_this_week_so_far_for_date(self, date, debit_type):
        day_of_week_index = date.weekday()
        money_withdrawn = 0
        if (day_of_week_index > 0):
                for i in range(0, day_of_week_index + 1):
                money_withdrawn += self.__get_debited_amount_on_date(date - timedelta(days = i), debit_type)

        return money_withdrawn    

def __get_debited_amount_on_date(self, date, debit_type):     
        amount = 0   
        for trn in self.Transactions:
                and trn.DebitType == debit_type \
                and trn.DateTime.date() == date.date():
                amount += trn.Amount

        return amount

No tests are failing.

Refactor 2: Factor out debit transaction operations

Withdrawing and transfering are very similar in our example. All that is changing is the marking of a transaction with a DebitType enum value.

Account:

def _withdraw(self, amount, datetime):
        return self.__debit(amount, datetime, DebitType.CashWithdrawal)

def _transfer_domestic(self, amount, datetime):
        return self.__debit(amount, datetime, DebitType.ElectronicTransferDomestic)

def _transfer_abroad(self, amount, datetime):
        return self.__debit(amount, datetime, DebitType.ElectronicTransferAbroad)

def __debit(self, amount, datetime, debit_type):
        if (self.__balance < amount):
                return OperationResult.InsufficientFunds

        self.__balance -= amount
        self.__transactions.add(Transaction(TransactionType.Debit, amount, datetime, debit_type))
        return OperationResult.Success

Tests are passing

Restriction 4: New deposits are exempt from withdrawal restrictions

Here we can either mark new deposits as “fresh” or query the account with every attempt to withdrawal/transfer to check if enough funds were deposited after a certain “restrictions ease” date to qualify. Both methods have the pros and cons. The first option is good for performance at withdrawals. The second option keeps the theoretical backend database (or several databases) free of changes. Given that capital controls are (hopefully) temporary, we don’t want to be in a situation where we’re changing a database (or several) and then reverting and retesting once the restrictions are over, so we’re going with option 2.

Let’s start with our test. The date we are selecting for this capital control relaxation is Monday 18/05/2020.

def test_new_deposits_are_exempt_from_withdrawal_restrictions(self):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)      
        datetimemock.now.return_value = datetime.datetime(2020, 5, 18, 0, 0, 0) ## Monday
        bank.deposit_to_account(account, 200)
        result_withdraw = bank.withdraw_from_account(account, 200)

        self.assertEqual(OperationResult.Success, result_withdraw)
        self.assertEqual(0, account.Balance)

Implementation in Bank:

def withdraw_from_account(self, account, amount):                
        if not self.__can_withdraw(account, amount):
            if not self.__is_fresh_deposit(account, amount):  
                return OperationResult.NotAllowed            

        return account._withdraw(amount, self.__datetimeprovider.now())

def __is_fresh_deposit(self, account, amount):
        total_deposits_after_cutoff_date = 0
        for trn in account.Transactions:
            if trn.Type == TransactionType.Credit and trn.DateTime == datetime(2020, 5, 18, 0, 0, 0):
                total_deposits_after_cutoff_date += trn.Amount        

        return amount == total_deposits_after_cutoff_date

This passes the test. Let’s refactor. The query in __is_fresh_deposit can be move to Account in the same fashion as previous queries.

    def _get_total_amount_for_credits(self, date):
        total_deposits_after_cutoff_date = 0
        for trn in self.Transactions:
            if trn.Type == TransactionType.Credit and trn.DateTime.date() == date.date():
                total_deposits_after_cutoff_date += trn.Amount        

        return total_deposits_after_cutoff_date

Bank becomes:

def __is_fresh_deposit(self, account, amount):
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits(datetime(2020, 5, 18, 12, 0, 0))
        return amount == total_deposits_after_cutoff_date

The relaxation date stays with the bank, the rule-setter.

We should also do away with the “magic” date in __is_fresh_deposit and move it to a class variable.

__start_date_for_fresh_transactions = datetime(2020, 5, 18, 12, 0, 0)

def __is_fresh_deposit(self, account, amount):
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits(__start_date_for_fresh_transactions)
        return amount == total_deposits_after_cutoff_date

We should also be able to “dig” into our previous savings that had been there before 18/05/2020 after exhausting any funds deposited to the account after that date.

@parameterized.expand([
        (18, 260, OperationResult.Success), # Monday, Day restrictions are eased for new deposits
        (19, 320, OperationResult.Success),
        (19, 321, OperationResult.NotAllowed)
])
def test_can_withdraw_daily_amount_with_rollover_plus_deposits_after_20200518(self, day_of_month_to_withdraw, withdrawal_amount, withdrawal_result):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)      
        datetimemock.now.return_value = datetime.datetime(2020, 5, 17, 12, 0, 0) # Day before restrictions are eased for new deposits
        bank.deposit_to_account(account, 120)

        datetimemock.now.return_value = datetime.datetime(2020, 5, day_of_month_to_withdraw, 12, 0, 0) 
        bank.deposit_to_account(account, 200)
        result = bank.withdraw_from_account(account, withdrawal_amount)

        self.assertEqual(withdrawal_result, result)

        if withdrawal_result==OperationResult.Success:
                self.assertEqual(320 - withdrawal_amount, account.Balance)
        else:
                self.assertEqual(320, account.Balance )

Bank

def withdraw_from_account(self, account, amount):       
        if self.__can_withdraw(account, amount) or self.__can_withdraw_with_fresh_deposit(account, amount):  
            return account._withdraw(amount, self.__datetimeprovider.now())

        return OperationResult.NotAllowed   

def __can_withdraw_with_fresh_deposit(self, account, amount):
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits_on_and_after_date(Bank.__start_date_for_fresh_transactions)
        day_of_week_index = self.__datetimeprovider.now().weekday()

        return amount <= total_deposits_after_cutoff_date + Bank.__max_daily_limit * (day_of_week_index + 1) - account._get_withdrawn_amount_this_week_so_far_for_date(self.__datetimeprovider.now())

In Account, _get_total_amount_for_credits is renamed and modified.

def _get_total_amount_for_credits_on_and_after_date(self, date):
        total_deposits_after_cutoff_date = 0
        for trn in self.Transactions:
                if trn.Type == TransactionType.Credit and trn.DateTime.date() >= date.date():
                total_deposits_after_cutoff_date += trn.Amount        

        return total_deposits_after_cutoff_date

Let’s assert that transfers abroad behave the same, with their own weekly $500 limit.

def test_can_transfer_abroad_above_weekly_limit_with_deposits_after_20200518(self):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)      
        datetimemock.now.return_value = datetime.datetime(2020, 5, 17, 12, 0, 0) # Day before restrictions are eased for new deposits
        bank.deposit_to_account(account, 500)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 19, 12, 0, 0) 
        bank.deposit_to_account(account, 500)
        result = bank.transfer_abroad(account, 1000)

        self.assertEqual(OperationResult.Success, result)

        self.assertEqual(0, account.Balance)

Bank

def transfer_abroad(self, account, amount):
        if self.__can_transfer_abroad(account, amount) or self.__can_transfer_abroad_with_fresh_deposit(account, amount):  
            return account._transfer_abroad(amount, self.__datetimeprovider.now())

        return OperationResult.NotAllowed
def __can_transfer_abroad_with_fresh_deposit(self, account, amount):
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits_on_and_after_date(Bank.__start_date_for_fresh_transactions)

        money_transfered_abroad_this_week = account._get_amount_transfered_abroad_this_week_so_far_for_date(self.__datetimeprovider.now())

        return amount == Bank.__max_weekly_bank_transfer_abroad_limit + total_deposits_after_cutoff_date - money_transfered_abroad_this_week

Cleanup

The OR conditional in the transfer_abroad method is in place in order to make sure existing, up-to-date tests aren’t broken when adding new rules. However, the bodies of __can_transfer_abroad and __can_transfer_abroad_with_fresh_deposit look a lot alike and it turns out that __can_transfer_abroad is a redundant check that can be removed without failing the tests. __can_transfer_abroad_with_fresh_deposit covers both “fresh” deposits as well as older ones. The same is true of the withdraw_from_account, so the extra check there is removed.

Asserting interwowen cash withdrawals and transfer abroad transactions

def test_can_withdraw_and_transfer_abroad_daily_amount_with_rollover_plus_deposits_after_20200518(self):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)  
        datetimemock.now.return_value = datetime.datetime(2020, 5, 17, 12, 0, 0) # Day before restrictions are eased for new deposits
        bank.deposit_to_account(account, 120)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 18, 12, 0, 0) 
        bank.deposit_to_account(account, 2000)
        bank.withdraw_from_account(account, 100)
        bank.withdraw_from_account(account, 100)        
        result = bank.transfer_abroad(account, 1920)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(0, account.Balance)

def test_can_withdraw_and_transfer_abroad_daily_amount_with_rollover_plus_deposits_after_20200518_over_limit_not_allowed(self):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)  
        datetimemock.now.return_value = datetime.datetime(2020, 5, 17, 12, 0, 0) # Day before restrictions are eased for new deposits
        bank.deposit_to_account(account, 2000)

        datetimemock.now.return_value = datetime.datetime(2020, 5, 18, 12, 0, 0) 
        bank.deposit_to_account(account, 1000)
        bank.withdraw_from_account(account, 200)       
        result = bank.transfer_abroad(account, 1400)

        self.assertEqual(OperationResult.NotAllowed, result)
        self.assertEqual(2800, account.Balance)

Before the first deposit, we always set the date to a date earlier than 18/05/2020 so the money debited are subject to restrictions

Bank

def __can_withdraw(self, account, amount):
        day_of_week_index = self.__datetimeprovider.now().weekday()
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits_on_and_after_date(Bank.__start_date_for_fresh_transactions)
        money_withdrawn_this_week_so_far_for_date = account._get_withdrawn_amount_this_week_so_far_for_date(self.__datetimeprovider.now())
        return amount <= total_deposits_after_cutoff_date + Bank.__max_daily_limit * (day_of_week_index + money_withdrawn_this_week_so_far_for_date

Account

def _get_debited_amount_this_week_so_far_for_date(self, date, debit_type):
        day_of_week_index = date.weekday()
        money_debited = 0
        for i in range(0, day_of_week_index + 1):
            money_debited += self.__get_debited_amount_on_date(date - timedelta(days = i), debit_type)

        return money_debited

And with yet another method extraction in Bank:

def __can_withdraw(self, account, amount):
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits_on_and_after_date(Bank.__start_date_for_fresh_transactions)
        money_withdrawn_this_week_so_far_for_date = account._get_withdrawn_amount_this_week_so_far_for_date(self.__datetimeprovider.now())
        return amount <= total_deposits_after_cutoff_date + self.__calculate_max_withdrawal_limit_on_current_day_of_week() - money_withdrawn_this_week_so_far_for_date
    
def __calculate_max_withdrawal_limit_on_current_day_of_week(self):
        day_of_week_index = self.__datetimeprovider.now().weekday()
        return Bank.__max_daily_limit * (day_of_week_index + 1)

def __can_transfer_abroad(self, account, amount):
        total_deposits_after_cutoff_date = account._get_total_amount_for_credits_on_and_after_date(Bank.__start_date_for_fresh_transactions)
        money_transfered_abroad_this_week = account._get_amount_transfered_abroad_this_week_so_far_for_date(self.__datetimeprovider.now())
        return amount <= total_deposits_after_cutoff_date + Bank.__max_weekly_bank_transfer_abroad_limit - money_transfered_abroad_this_week

Restriction 5: extend time limit to two weeks

We’ll start with cash withdrawals. As each week starts on Monday, we’ll set a Monday to act as the starting day the two weeks will start from, and will be renewed two Mondays later.

def test_missed_daily_cash_withdrawals_can_be_backed_up_for_two_weeks(self):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)  
        datetimemock.now.return_value = datetime.datetime(2020, 5, 1, 12, 0, 0) # This is a date before restrictions are eased (18-5-2020) for new deposits
        bank.deposit_to_account(account, 2000)

        datetimemock.now.return_value = datetime.datetime(2020, 6, 20, 14, 0, 0)
        result = bank.withdraw_from_account(account, 840)

        self.assertEqual(OperationResult.Success, result)
        self.assertEqual(1160, account.Balance)

Again, we deposit some money before the date Iteration 4 takes effect so they are subject to the capital control restrictions as the rules don’t apply to any money deposited afterwards. Then, at a date in the future, we withdraw $840. We pick 1-Jun-2020 as the date Iteraion 5 takes effect.

In Bank:

def __calculate_max_withdrawal_limit_on_current_day_of_week(self):  
        now =  self.__datetimeprovider.now().date()

        if (now >= datetime(2020, 6, 1).date()):
            day_of_week_index2 = (now - datetime(2020, 6, 1).date()).days
            return Bank.__max_daily_limit * (day_of_week_index2 + 1)

        day_of_week_index = self.__datetimeprovider.now().weekday()
        return Bank.__max_daily_limit * (day_of_week_index + 1)

A bit crude, for now, but the test has become green. Expectedly, some tests have failed after changing the above method. These pertain to cash withdrawals (as this is what we’ve just changed now) and the maximum withdrawal amount that spanned across a week but it now spans across two.

Our next step is to assert that additional funds cannot be withdrawn until the two-week period has elapsed.

@parameterized.expand([
        (14, 900),
        (15, 900),
        (16, 121),
])
def test_missed_daily_cash_withdrawals_over_two_weeks_old_does_not_roll_over_for_old_deposits(self, day, withdrawal_amount):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)  
        datetimemock.now.return_value = datetime.datetime(2020, 5, 1, 12, 0, 0) # This is a date before restrictions are eased (18-5-2020) for new deposits
        bank.deposit_to_account(account, 2000)

        datetimemock.now.return_value = datetime.datetime(2020, 6, day, 12, 0, 0) # 20 days after 1-6-2020, when missed cash allowance started rolling over in the space of two weeks
        result = bank.withdraw_from_account(account, withdrawal_amount)

        self.assertEqual(OperationResult.NotAllowed, result)

That’s failing, so we’re adding this to Bank:


def __calculate_max_withdrawal_limit_on_current_day_of_week(self):  
        now =  self.__datetimeprovider.now().date()

        day_of_week_index_two_weeks = (now - datetime(2020, 6, 1).date()).days + 1

        if day_of_week_index_two_weeks > 14:
            day_of_week_index_two_weeks = day_of_week_index_two_weeks - 14

        return Bank.__max_daily_limit * day_of_week_index_two_weeks

This will make test cases for test_missed_daily_cash_withdrawals_over_two_weeks_old_does_not_roll_over_for_old_deposits pass provided these do not exceed 28 days after 1/6/2020. So, let’s add test cases for July and fail them!

@parameterized.expand([
       (14, 6, 900),
       (15, 6, 900),
       (16, 6, 121),
       (30, 6, 121),
       (30, 7, 241),
])
def test_missed_daily_cash_withdrawals_over_two_weeks_old_does_not_roll_over_for_old_deposits(self, day, month, withdrawal_amount):
        datetimemock = Mock()        
        account = Account() 
        bank = Bank(datetimemock)  
        datetimemock.now.return_value = datetime.datetime(2020, 5, 1, 12, 0, 0) # This is a date before restrictions are eased (18-5-2020) for new deposits
        bank.deposit_to_account(account, 2000)

        datetimemock.now.return_value = datetime.datetime(2020, month, day, 12, 0, 0) # 20 days after 1-6-2020, when missed cash allowance started rolling over in the space of two weeks
        result = bank.withdraw_from_account(account, withdrawal_amount)

        self.assertEqual(OperationResult.NotAllowed, result)

You see where this is going. We need to think in terms of 14-day intervals.

Bank

def __calculate_max_withdrawal_limit_on_current_day_of_week(self):  
        now =  self.__datetimeprovider.now().date()

        day_of_week_index_two_weeks = ((now - datetime(2020, 6, 1).date()).days + 1) % 14

        if day_of_week_index_two_weeks == 0:
            day_of_week_index_two_weeks = 14

        return Bank.__max_daily_limit * day_of_week_index_two_weeks

A little refactoring is in order. In Bank:


__start_of_two_week_periods = datetime(2020, 6, 1).date()

def __calculate_max_withdrawal_limit_on_current_day_of_week(self):  
        now =  self.__datetimeprovider.now().date()

        day_of_week_index_two_weeks = ((now - Bank.__start_of_two_week_periods).days + 1) % 14
        day_of_week_index_two_weeks = self.__adjust_for_end_of_two_week_period(day_of_week_index_two_weeks)

        return Bank.__max_daily_limit * day_of_week_index_two_weeks

def __adjust_for_end_of_two_week_period(self, day_of_week_index_two_weeks):
        return 14 if day_of_week_index_two_weeks == 0 else day_of_week_index_two_weeks

There are more tests failing still. That is happening because restrictions are now reset every other week instead of every week and the withdrawal amount has proportionately increased. We need to go through all the failing tests one-by-one and adjust them. I started with the smaller ones, such as test_withdraw_creates_transaction and proceeded to the longer ones. Tests are updated with new dates post 1-6-2020 as the release will happen after a specific date and the rules will be referencing that specific date. Edge cases which previously failed are also updated with appropriate dates and amounts. We’re not losing generality, just like we didn’t lose generality implementing Iteration 4 as the releases would take place on the dates that are referenced in Bank as class variables.

Final Thoughts

As mentioned I didn’t initially have a mock to generate dates at will, because YAGNI, yet I didn’t want to reference a datetime straight from the Bank class but wanted to have it as a constructor dependency in order for the Bank class to advertise the fact that it needs a datetime object.

As I was changing the implementation code, existing tests acted as a safeguard that ensured that unrelated functionality didn’t break. This is one of the benefits of having unit tests and a good coverage apart from all the other benefits, such as not overengineering your implementation and reducing regressions.

If requirements changed in a future iteration, there was a time I would go back to existing unit tests and change the assertions and/or parameters that went into the “Act” phase of the test, then pass that change test in the production code. That would work for me if the unit tests were fewer than they are in this instance. If there are heaps of unit tests, these days I write a new test, pass it and, in case any old tests are broken, understand why and fix them (or fix the production code, or both). It could be that they are failing because they’ve become outdated with the changing of the requirements and some may even need to be removed. Any such possibilities are spotted at once once you’ve implemented a change and tests start to fail.

One way to manage releases which are date-dependant as Iterations 4 and 5 in this exercise is to release ahead of time instead of the midnight of the day past the deadline and have two branches of logic depending on what date it is. This implementation assumes that production systems are shut down for maintenance on the midnight past the deadline and the new code is deployed during that window. It’s all about what approach you;d like to take.