Implemented "pay" action
This commit is contained in:
93
starling
93
starling
@@ -3,15 +3,22 @@
|
||||
#-----------------------------------------------------------------------------#
|
||||
|
||||
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 json
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
import types
|
||||
import urllib
|
||||
|
||||
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]
|
||||
if self.type == 'digits':
|
||||
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':
|
||||
return self.answer in self.values
|
||||
if self.type == 'uid':
|
||||
return uid_re.match(self.answer) != None
|
||||
return uid_re.fullmatch(self.answer) != None
|
||||
else:
|
||||
raise Exception(f'Unknown value type "{self.type}" for "{self.description}"')
|
||||
|
||||
@@ -84,6 +91,26 @@ class Form:
|
||||
sys.exit(1)
|
||||
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:
|
||||
|
||||
new_payee_form = Form(
|
||||
@@ -103,8 +130,11 @@ class StarlingClient:
|
||||
Question('account_description', 'Account description', 'string', (1, 255,)),
|
||||
)
|
||||
|
||||
amount_re = re.compile(r'^(\d+)\.(\d\d)$')
|
||||
|
||||
def __init__(self):
|
||||
self.tokens = self.read_tokens('main', 'payments')
|
||||
self.api_key = StarlingKey('starling-api-key')
|
||||
|
||||
def read_tokens(self, *names):
|
||||
tokens = {}
|
||||
@@ -126,7 +156,22 @@ class StarlingClient:
|
||||
headers = {
|
||||
'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)
|
||||
|
||||
def delete(self, path, token='main'):
|
||||
@@ -146,6 +191,13 @@ class StarlingClient:
|
||||
sys.exit(1)
|
||||
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 ###
|
||||
|
||||
def account_holder(self):
|
||||
@@ -157,8 +209,8 @@ class StarlingClient:
|
||||
def accounts(self):
|
||||
return self.get('accounts')
|
||||
|
||||
def balance(self, accountUid):
|
||||
return self.get(f'accounts/{accountUid}/balance')
|
||||
def balance(self, account_uid):
|
||||
return self.get(f'accounts/{account_uid}/balance')
|
||||
|
||||
def payees(self, data=None):
|
||||
if data is None:
|
||||
@@ -168,10 +220,13 @@ class StarlingClient:
|
||||
def payees_account(self, payee_uid, 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 ###
|
||||
|
||||
def formatted_balance(self, accountUid):
|
||||
balance = self.balance(accountUid).effectiveBalance
|
||||
def formatted_balance(self, account_uid):
|
||||
balance = self.balance(account_uid).effectiveBalance
|
||||
if balance.currency == 'GBP':
|
||||
symbol = '£'
|
||||
elif balance.currency == 'EUR':
|
||||
@@ -287,6 +342,10 @@ class StarlingClient:
|
||||
sys.exit(1)
|
||||
|
||||
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
|
||||
for account in self.accounts().accounts:
|
||||
if account.accountType == 'PRIMARY':
|
||||
@@ -294,7 +353,17 @@ class StarlingClient:
|
||||
break
|
||||
else:
|
||||
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')
|
||||
if count > 2:
|
||||
parser.error(f'Too many arguments for "{action} {subaction}" action')
|
||||
payee_uid = action_args.pop(0)
|
||||
client.payee_del(payee_uid)
|
||||
client.payee_del(*action_args)
|
||||
else:
|
||||
parser.error(f'Unknown "{action} {subaction}" action')
|
||||
elif action == 'account':
|
||||
@@ -355,8 +423,7 @@ elif action == 'account':
|
||||
parser.error(f'Too few arguments for "{action} {subaction}" action')
|
||||
if count > 2:
|
||||
parser.error(f'Too many arguments for "{action} {subaction}" action')
|
||||
account_uid = action_args.pop(0)
|
||||
client.account_del(account_uid)
|
||||
client.account_del(*actuon_args)
|
||||
else:
|
||||
parser.error(f'Unknown "{action} {subaction}" action')
|
||||
elif action == 'pay':
|
||||
|
||||
Reference in New Issue
Block a user