Implemented "pay" action
This commit is contained in:
93
starling
93
starling
@@ -3,15 +3,22 @@
|
|||||||
#-----------------------------------------------------------------------------#
|
#-----------------------------------------------------------------------------#
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
|
||||||
|
from cryptography.hazmat.primitives.hashes import SHA512
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
import urllib
|
||||||
|
|
||||||
base_url = 'https://api.starlingbank.com/api/v2'
|
base_url = 'https://api.starlingbank.com/api/v2'
|
||||||
uid_re = re.compile(r'^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$')
|
uid_re = re.compile(r'[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}')
|
||||||
|
|
||||||
#-----------------------------------------------------------------------------#
|
#-----------------------------------------------------------------------------#
|
||||||
|
|
||||||
@@ -50,11 +57,11 @@ class Question:
|
|||||||
return size >= self.values[0] and size <= self.values[1]
|
return size >= self.values[0] and size <= self.values[1]
|
||||||
if self.type == 'digits':
|
if self.type == 'digits':
|
||||||
size = len(self.answer)
|
size = len(self.answer)
|
||||||
return size == self.values and re.match('^\d+$', self.answer)
|
return size == self.values and re.fullmatch('\d+', self.answer)
|
||||||
if self.type == 'enum':
|
if self.type == 'enum':
|
||||||
return self.answer in self.values
|
return self.answer in self.values
|
||||||
if self.type == 'uid':
|
if self.type == 'uid':
|
||||||
return uid_re.match(self.answer) != None
|
return uid_re.fullmatch(self.answer) != None
|
||||||
else:
|
else:
|
||||||
raise Exception(f'Unknown value type "{self.type}" for "{self.description}"')
|
raise Exception(f'Unknown value type "{self.type}" for "{self.description}"')
|
||||||
|
|
||||||
@@ -84,6 +91,26 @@ class Form:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return dict(map(lambda q: (q.name, q.answer), self.questions))
|
return dict(map(lambda q: (q.name, q.answer), self.questions))
|
||||||
|
|
||||||
|
class StarlingKey:
|
||||||
|
|
||||||
|
algorithm = 'ecdsa-sha512'
|
||||||
|
uid_line_re = re.compile(r'^Key Uid:\s*(' + uid_re.pattern + r')\s*$')
|
||||||
|
|
||||||
|
def __init__(self, name):
|
||||||
|
path = f'keys/{name}.key'
|
||||||
|
with io.open(path, 'rb') as fh:
|
||||||
|
line = fh.readline().decode('utf-8').rstrip()
|
||||||
|
match = self.uid_line_re.fullmatch(line)
|
||||||
|
if match is None:
|
||||||
|
sys.exit(f'ERROR: missing/malformed "Key Uid:" line in "{path}"')
|
||||||
|
self.uid = match.group(1)
|
||||||
|
self.key = load_pem_private_key(fh.read(), None)
|
||||||
|
#print(f'Loaded key with UID {self.uid} from "{path}"')
|
||||||
|
|
||||||
|
def sign(self, content):
|
||||||
|
signature = self.key.sign(content.encode('utf-8'), ECDSA(SHA512()))
|
||||||
|
return base64.b64encode(signature).decode('utf-8')
|
||||||
|
|
||||||
class StarlingClient:
|
class StarlingClient:
|
||||||
|
|
||||||
new_payee_form = Form(
|
new_payee_form = Form(
|
||||||
@@ -103,8 +130,11 @@ class StarlingClient:
|
|||||||
Question('account_description', 'Account description', 'string', (1, 255,)),
|
Question('account_description', 'Account description', 'string', (1, 255,)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
amount_re = re.compile(r'^(\d+)\.(\d\d)$')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tokens = self.read_tokens('main', 'payments')
|
self.tokens = self.read_tokens('main', 'payments')
|
||||||
|
self.api_key = StarlingKey('starling-api-key')
|
||||||
|
|
||||||
def read_tokens(self, *names):
|
def read_tokens(self, *names):
|
||||||
tokens = {}
|
tokens = {}
|
||||||
@@ -126,7 +156,22 @@ class StarlingClient:
|
|||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.tokens[token]}',
|
'Authorization': f'Bearer {self.tokens[token]}',
|
||||||
}
|
}
|
||||||
response = requests.put(url, json=data, headers=headers)
|
response = requests.put(url, headers=headers, json=data)
|
||||||
|
return self.parse_response(response)
|
||||||
|
|
||||||
|
def signed_put(self, path, data, token='main'):
|
||||||
|
url = f'{base_url}/{path}'
|
||||||
|
body = (json.dumps(data) + '\n').encode('utf-8')
|
||||||
|
date = datetime.datetime.utcnow().isoformat() + '+00:00'
|
||||||
|
digest = base64.b64encode(hashlib.sha512(body).digest()).decode('utf-8')
|
||||||
|
signature = self.make_signature('put', url, date, digest)
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {self.tokens[token]}; Signature {signature}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Date': date,
|
||||||
|
'Digest': digest,
|
||||||
|
}
|
||||||
|
response = requests.put(url, headers=headers, data=body)
|
||||||
return self.parse_response(response)
|
return self.parse_response(response)
|
||||||
|
|
||||||
def delete(self, path, token='main'):
|
def delete(self, path, token='main'):
|
||||||
@@ -146,6 +191,13 @@ class StarlingClient:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj))
|
return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj))
|
||||||
|
|
||||||
|
def make_signature(self, method, url, date, digest):
|
||||||
|
path = urllib.parse.urlparse(url).path
|
||||||
|
headers = '(request-target) Date Digest'
|
||||||
|
content = f'(request-target): {method} {path}\nDate: {date}\nDigest: {digest}'
|
||||||
|
signature = self.api_key.sign(content)
|
||||||
|
return f'keyid="{self.api_key.uid}",algorithm="{StarlingKey.algorithm}",headers="{headers}",signature="{signature}"'
|
||||||
|
|
||||||
### Low-level API wrappers ###
|
### Low-level API wrappers ###
|
||||||
|
|
||||||
def account_holder(self):
|
def account_holder(self):
|
||||||
@@ -157,8 +209,8 @@ class StarlingClient:
|
|||||||
def accounts(self):
|
def accounts(self):
|
||||||
return self.get('accounts')
|
return self.get('accounts')
|
||||||
|
|
||||||
def balance(self, accountUid):
|
def balance(self, account_uid):
|
||||||
return self.get(f'accounts/{accountUid}/balance')
|
return self.get(f'accounts/{account_uid}/balance')
|
||||||
|
|
||||||
def payees(self, data=None):
|
def payees(self, data=None):
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -168,10 +220,13 @@ class StarlingClient:
|
|||||||
def payees_account(self, payee_uid, data):
|
def payees_account(self, payee_uid, data):
|
||||||
return self.put(f'payees/{payee_uid}/account', data)
|
return self.put(f'payees/{payee_uid}/account', data)
|
||||||
|
|
||||||
|
def payment(self, account_uid, category_uid, data):
|
||||||
|
return self.signed_put(f'payments/local/account/{account_uid}/category/{category_uid}', data, token='payments')
|
||||||
|
|
||||||
### Mid-level methods to munge the data from the low-level calls ###
|
### Mid-level methods to munge the data from the low-level calls ###
|
||||||
|
|
||||||
def formatted_balance(self, accountUid):
|
def formatted_balance(self, account_uid):
|
||||||
balance = self.balance(accountUid).effectiveBalance
|
balance = self.balance(account_uid).effectiveBalance
|
||||||
if balance.currency == 'GBP':
|
if balance.currency == 'GBP':
|
||||||
symbol = '£'
|
symbol = '£'
|
||||||
elif balance.currency == 'EUR':
|
elif balance.currency == 'EUR':
|
||||||
@@ -287,6 +342,10 @@ class StarlingClient:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def pay(self, payee_uid, amount, ref):
|
def pay(self, payee_uid, amount, ref):
|
||||||
|
match = self.amount_re.match(amount)
|
||||||
|
if match is None:
|
||||||
|
sys.exit(f'ERROR: bad amount "{amount}" - must be number with two decimal places')
|
||||||
|
minor_units = (int(match.group(1)) * 100) + int(match.group(2))
|
||||||
primary = None
|
primary = None
|
||||||
for account in self.accounts().accounts:
|
for account in self.accounts().accounts:
|
||||||
if account.accountType == 'PRIMARY':
|
if account.accountType == 'PRIMARY':
|
||||||
@@ -294,7 +353,17 @@ class StarlingClient:
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
sys.exit('ERROR: No PRIMARY account found')
|
sys.exit('ERROR: No PRIMARY account found')
|
||||||
print(primary)
|
data = {
|
||||||
|
'externalIdentifier': str(datetime.datetime.utcnow().timestamp()),
|
||||||
|
'destinationPayeeAccountUid': payee_uid,
|
||||||
|
'reference': ref,
|
||||||
|
'amount': {
|
||||||
|
'currency': 'GBP',
|
||||||
|
'minorUnits': minor_units,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = self.payment(primary.accountUid, primary.defaultCategory, data)
|
||||||
|
print(f'Successfully created payment order with UID {response.paymentOrderUid}')
|
||||||
|
|
||||||
#-----------------------------------------------------------------------------#
|
#-----------------------------------------------------------------------------#
|
||||||
|
|
||||||
@@ -338,8 +407,7 @@ elif action == 'payee':
|
|||||||
parser.error(f'Too few arguments for "{action} {subaction}" action')
|
parser.error(f'Too few arguments for "{action} {subaction}" action')
|
||||||
if count > 2:
|
if count > 2:
|
||||||
parser.error(f'Too many arguments for "{action} {subaction}" action')
|
parser.error(f'Too many arguments for "{action} {subaction}" action')
|
||||||
payee_uid = action_args.pop(0)
|
client.payee_del(*action_args)
|
||||||
client.payee_del(payee_uid)
|
|
||||||
else:
|
else:
|
||||||
parser.error(f'Unknown "{action} {subaction}" action')
|
parser.error(f'Unknown "{action} {subaction}" action')
|
||||||
elif action == 'account':
|
elif action == 'account':
|
||||||
@@ -355,8 +423,7 @@ elif action == 'account':
|
|||||||
parser.error(f'Too few arguments for "{action} {subaction}" action')
|
parser.error(f'Too few arguments for "{action} {subaction}" action')
|
||||||
if count > 2:
|
if count > 2:
|
||||||
parser.error(f'Too many arguments for "{action} {subaction}" action')
|
parser.error(f'Too many arguments for "{action} {subaction}" action')
|
||||||
account_uid = action_args.pop(0)
|
client.account_del(*actuon_args)
|
||||||
client.account_del(account_uid)
|
|
||||||
else:
|
else:
|
||||||
parser.error(f'Unknown "{action} {subaction}" action')
|
parser.error(f'Unknown "{action} {subaction}" action')
|
||||||
elif action == 'pay':
|
elif action == 'pay':
|
||||||
|
|||||||
Reference in New Issue
Block a user