Files
starling-app/starling

257 lines
8.9 KiB
Python
Executable File

#!/usr/bin/env -S python3 -ttuI
# -*- python -*-
#-----------------------------------------------------------------------------#
import argparse
import io
import json
import re
import requests
import sys
import types
base_url = 'https://api.starlingbank.com/api/v2'
#-----------------------------------------------------------------------------#
class Question:
def __init__(self, name, description, type, values):
self.name = name
self.description = description
self.type = type
self.values = values
self.answer = None
def ask(self):
while True:
sys.stdout.write(f'{self.description} ({self.type_description()}): ')
self.answer = sys.stdin.readline().rstrip('\r\n')
if self.valid_answer():
break
print(f'Invalid answer {self.answer!r}, please try again')
def type_description(self):
if self.type == 'string':
return f'{self.values[0]} - {self.values[1]} characters'
if self.type == 'digits':
return f'exactly {self.values} digits'
if self.type == 'enum':
return ', '.join(self.values)
else:
raise Exception(f'Unknown value type {self.type} for "{self.description}"')
def valid_answer(self):
if self.type == 'string':
size = len(self.answer)
return size >= self.values[0] and size <= self.values[1]
if self.type == 'digits':
size = len(self.answer)
return size == self.values and re.match('^\d+$', self.answer)
if self.type == 'enum':
return self.answer in self.values
else:
raise Exception(f'Unknown value type {self.type} for "{self.description}"')
class Form:
def __init__(self, topic, *questions):
self.topic = topic
self.questions = questions
def complete(self):
print(f'Please enter the details for the {self.topic}:')
print('')
for question in self.questions:
question.ask()
print('')
print(f'Please check the details for the {self.topic}:')
print('')
for question in self.questions:
print(f'{question.description}: {question.answer}')
print('')
while True:
sys.stdout.write('Are these details corrent (y/n)? ')
answer = sys.stdin.readline().rstrip('\r\n')
if answer == 'y':
break
if answer == 'n':
sys.exit(1)
return dict(map(lambda q: (q.name, q.answer), self.questions))
class StarlingClient:
new_payee_form = Form(
'new payee',
Question('payee_type', 'Payee type', 'enum', ('BUSINESS', 'INDIVIDUAL',)),
Question('payee_name', 'Payee\'s name', 'string', (1, 255,)),
Question('sort_code', 'Sort code', 'digits', 6),
Question('account_number', 'Account number', 'digits', 8),
Question('account_description', 'Account description', 'string', (1, 255,)),
)
def __init__(self):
self.tokens = self.read_tokens('main', 'payments')
def read_tokens(self, *names):
tokens = {}
for name in names:
with io.open(f'tokens/{name}.txt') as fh:
tokens[name] = fh.read().rstrip()
return tokens
def get(self, path, token='main'):
url = f'{base_url}/{path}'
headers = {
'Authorization': f'Bearer {self.tokens[token]}',
}
response = requests.get(url, headers=headers)
response.raise_for_status()
return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj))
def put(self, path, data, token='main'):
url = f'{base_url}/{path}'
headers = {
'Authorization': f'Bearer {self.tokens[token]}',
}
response = requests.put(url, json=data, headers=headers)
response.raise_for_status()
return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj))
### Low-level API wrappers ###
def account_holder(self):
return self.get('account-holder')
def account_holder_individual(self):
return self.get('account-holder/individual')
def accounts(self):
return self.get('accounts')
def balance(self, accountUid):
return self.get(f'accounts/{accountUid}/balance')
def payees(self, data=None):
if data is None:
return self.get('payees')
return self.put('payees', data)
### Mid-level methods to munge the data from the low-level calls ###
def formatted_balance(self, accountUid):
balance = self.balance(accountUid).effectiveBalance
if balance.currency == 'GBP':
symbol = '£'
elif balance.currency == 'EUR':
symbol = ''
else:
sys.exit(f'ERROR: Unsupported currency {balance.currency}')
major_units = int(balance.minorUnits / 100)
minor_units = balance.minorUnits % 100
return f'{symbol}{major_units}.{minor_units:02d}'
### High-level methods which correspond to actions ###
def default_action(self):
holder = self.account_holder()
holder_type = holder.accountHolderType
if holder_type == 'INDIVIDUAL':
details = self.account_holder_individual()
else:
sys.exit(f'ERROR: Unsupported account holder type {holder_type}')
print('Customer details:')
print(f' Name: {details.firstName} {details.lastName}')
print(f' Email: <{details.email}>')
print(f' Phone: {details.phone}')
accounts = self.accounts().accounts
count = len(accounts)
if count == 0:
print('This customer has no bank accounts.')
else:
if count == 1:
print('This customer has one bank account:')
else:
print(f'This customer has {count} bank accounts:')
for account in accounts:
balance = self.formatted_balance(account.accountUid)
print(f' {account.name} ({account.accountType}): {balance}')
print(f' Default category: {account.defaultCategory}')
def list_payees(self):
payees = self.payees().payees
count = len(payees)
if count == 0:
print('There are no payees for this customer.')
else:
if count == 1:
print('There is one payee for this customer:')
else:
print(f'There are {count} payees for this customer:')
for payee in payees:
print(f' {payee.payeeName} ({payee.payeeType})')
def add_payee(self):
details = self.new_payee_form.complete()
data = {
'payeeName': details['payee_name'],
'payeeType': details['payee_type'],
'accounts': [
{
'description': details['account_description'],
'defaultAccount': True,
'countryCode': 'GB',
'accountIdentifier': details['account_number'],
'bankIdentifier': details['sort_code'],
'bankIdentifierType': 'SORT_CODE',
},
],
}
response = self.payees(data)
if response.success:
print(f'Successfully created payee with UID {response.payeeUid}')
else:
print(f'Failed to create payee: {response}')
sys.exit(1)
#-----------------------------------------------------------------------------#
parser = argparse.ArgumentParser(
usage='%(prog)s [options] [action [arg ...]]',
description='Interacts with Starling Bank\'s API to manage bank accounts.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''recognised actions and args:
<none> - show customer and account details
payees - list the customer's payees
add_payee - adds a new payee
''')
parser.add_argument('-t', '--test', action='store_true', default=False,
help='use test servers rather than live servers')
parser.add_argument('-v', '--verbose', action='store_true', default=False,
help='print XML requests and responses')
parser.add_argument('action', help='which action to perform', nargs='*')
args = parser.parse_args()
client = StarlingClient()
action_args = args.action
if len(action_args) == 0:
client.default_action()
sys.exit(0)
action = action_args.pop(0)
count = len(action_args)
if action == 'payees':
if count > 0:
parser.error(f'Too many arguments for "{action}" action')
client.list_payees()
elif action == 'add_payee':
if count > 0:
parser.error(f'Too many arguments for "{action}" action')
client.add_payee()
else:
parser.error(f'Unknown action "{action}"')
#-----------------------------------------------------------------------------#