Implemented "pay" action

This commit is contained in:
2023-09-09 15:05:44 +00:00
parent f141a3de13
commit b000e6ee80

View File

@@ -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':