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