Compare commits
3 Commits
b000e6ee80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41afddfaff | |||
| 2a4aff75f0 | |||
| 0a26671068 |
107
starling
107
starling
@@ -11,6 +11,7 @@ import datetime
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
import sys
|
import sys
|
||||||
@@ -96,8 +97,8 @@ class StarlingKey:
|
|||||||
algorithm = 'ecdsa-sha512'
|
algorithm = 'ecdsa-sha512'
|
||||||
uid_line_re = re.compile(r'^Key Uid:\s*(' + uid_re.pattern + r')\s*$')
|
uid_line_re = re.compile(r'^Key Uid:\s*(' + uid_re.pattern + r')\s*$')
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, root, name):
|
||||||
path = f'keys/{name}.key'
|
path = f'{root}/keys/{name}.key'
|
||||||
with io.open(path, 'rb') as fh:
|
with io.open(path, 'rb') as fh:
|
||||||
line = fh.readline().decode('utf-8').rstrip()
|
line = fh.readline().decode('utf-8').rstrip()
|
||||||
match = self.uid_line_re.fullmatch(line)
|
match = self.uid_line_re.fullmatch(line)
|
||||||
@@ -132,14 +133,15 @@ class StarlingClient:
|
|||||||
|
|
||||||
amount_re = re.compile(r'^(\d+)\.(\d\d)$')
|
amount_re = re.compile(r'^(\d+)\.(\d\d)$')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
self.tokens = self.read_tokens('main', 'payments')
|
self.tokens = self.read_tokens('main', 'payments')
|
||||||
self.api_key = StarlingKey('starling-api-key')
|
self.api_key = StarlingKey(root, 'starling-api-key')
|
||||||
|
|
||||||
def read_tokens(self, *names):
|
def read_tokens(self, *names):
|
||||||
tokens = {}
|
tokens = {}
|
||||||
for name in names:
|
for name in names:
|
||||||
with io.open(f'tokens/{name}.txt') as fh:
|
with io.open(f'{self.root}/tokens/{name}.txt') as fh:
|
||||||
tokens[name] = fh.read().rstrip()
|
tokens[name] = fh.read().rstrip()
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
@@ -189,7 +191,12 @@ class StarlingClient:
|
|||||||
print(f'Response:')
|
print(f'Response:')
|
||||||
print(response.text)
|
print(response.text)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
if response.status_code == 204: # No Content
|
||||||
|
return None
|
||||||
|
try:
|
||||||
return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj))
|
return json.loads(response.text, object_hook=lambda obj: types.SimpleNamespace(**obj))
|
||||||
|
except:
|
||||||
|
sys.exit(f'ERROR: failed to parse response:\n{response}')
|
||||||
|
|
||||||
def make_signature(self, method, url, date, digest):
|
def make_signature(self, method, url, date, digest):
|
||||||
path = urllib.parse.urlparse(url).path
|
path = urllib.parse.urlparse(url).path
|
||||||
@@ -223,18 +230,26 @@ class StarlingClient:
|
|||||||
def payment(self, account_uid, category_uid, 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')
|
return self.signed_put(f'payments/local/account/{account_uid}/category/{category_uid}', data, token='payments')
|
||||||
|
|
||||||
|
def spaces(self, account_uid):
|
||||||
|
return self.get(f'account/{account_uid}/spaces')
|
||||||
|
|
||||||
### 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, account_uid):
|
def get_primary_account(self):
|
||||||
balance = self.balance(account_uid).effectiveBalance
|
for account in self.accounts().accounts:
|
||||||
if balance.currency == 'GBP':
|
if account.accountType == 'PRIMARY':
|
||||||
|
return account
|
||||||
|
sys.exit('ERROR: No PRIMARY account found')
|
||||||
|
|
||||||
|
def format_amount(self, amount):
|
||||||
|
if amount.currency == 'GBP':
|
||||||
symbol = '£'
|
symbol = '£'
|
||||||
elif balance.currency == 'EUR':
|
elif amount.currency == 'EUR':
|
||||||
symbol = '€'
|
symbol = '€'
|
||||||
else:
|
else:
|
||||||
sys.exit(f'ERROR: Unsupported currency {balance.currency}')
|
sys.exit(f'ERROR: Unsupported currency {amount.currency}')
|
||||||
major_units = int(balance.minorUnits / 100)
|
major_units = int(amount.minorUnits / 100)
|
||||||
minor_units = balance.minorUnits % 100
|
minor_units = amount.minorUnits % 100
|
||||||
return f'{symbol}{major_units}.{minor_units:02d}'
|
return f'{symbol}{major_units}.{minor_units:02d}'
|
||||||
|
|
||||||
### High-level methods which correspond to actions ###
|
### High-level methods which correspond to actions ###
|
||||||
@@ -262,9 +277,9 @@ class StarlingClient:
|
|||||||
else:
|
else:
|
||||||
print(f'This holder has {count} accounts:')
|
print(f'This holder has {count} accounts:')
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
balance = self.formatted_balance(account.accountUid)
|
balance = self.balance(account.accountUid).effectiveBalance
|
||||||
print(f' {account.name}:')
|
print(f' {account.name}:')
|
||||||
print(f' Balance: {balance}')
|
print(f' Balance: {self.format_amount(balance)}')
|
||||||
print(f' Account type: {account.accountType}')
|
print(f' Account type: {account.accountType}')
|
||||||
print(f' Account UID: {account.accountUid}')
|
print(f' Account UID: {account.accountUid}')
|
||||||
print(f' Default category: {account.defaultCategory}')
|
print(f' Default category: {account.defaultCategory}')
|
||||||
@@ -341,18 +356,24 @@ class StarlingClient:
|
|||||||
print(f'Failed to create account: {response}')
|
print(f'Failed to create account: {response}')
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
def account_del(self, account_uid):
|
||||||
|
payee_uid = self.get_payee_uid_from_account(account_uid)
|
||||||
|
self.delete(f'payees/{payee_uid}/account/{account_uid}')
|
||||||
|
print(f'Successfully deleted account {account_uid} for payee {payee_uid}')
|
||||||
|
|
||||||
|
def get_payee_uid_from_account(self, account_uid):
|
||||||
|
for payee in self.payees().payees:
|
||||||
|
for account in payee.accounts:
|
||||||
|
if account.payeeAccountUid == account_uid:
|
||||||
|
return payee.payeeUid
|
||||||
|
sys.exit(f'ERROR: Can\'t find payee for account {account_uid}')
|
||||||
|
|
||||||
def pay(self, payee_uid, amount, ref):
|
def pay(self, payee_uid, amount, ref):
|
||||||
match = self.amount_re.match(amount)
|
match = self.amount_re.match(amount)
|
||||||
if match is None:
|
if match is None:
|
||||||
sys.exit(f'ERROR: bad amount "{amount}" - must be number with two decimal places')
|
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))
|
minor_units = (int(match.group(1)) * 100) + int(match.group(2))
|
||||||
primary = None
|
account = self.get_primary_account()
|
||||||
for account in self.accounts().accounts:
|
|
||||||
if account.accountType == 'PRIMARY':
|
|
||||||
primary = account
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
sys.exit('ERROR: No PRIMARY account found')
|
|
||||||
data = {
|
data = {
|
||||||
'externalIdentifier': str(datetime.datetime.utcnow().timestamp()),
|
'externalIdentifier': str(datetime.datetime.utcnow().timestamp()),
|
||||||
'destinationPayeeAccountUid': payee_uid,
|
'destinationPayeeAccountUid': payee_uid,
|
||||||
@@ -362,9 +383,39 @@ class StarlingClient:
|
|||||||
'minorUnits': minor_units,
|
'minorUnits': minor_units,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = self.payment(primary.accountUid, primary.defaultCategory, data)
|
response = self.payment(account.accountUid, account.defaultCategory, data)
|
||||||
print(f'Successfully created payment order with UID {response.paymentOrderUid}')
|
print(f'Successfully created payment order with UID {response.paymentOrderUid}')
|
||||||
|
|
||||||
|
def list_spaces(self):
|
||||||
|
account = self.get_primary_account()
|
||||||
|
result = self.spaces(account.accountUid)
|
||||||
|
self.print_savings_goals(result.savingsGoals)
|
||||||
|
self.print_spending_spaces(result.spendingSpaces)
|
||||||
|
|
||||||
|
def print_savings_goals(self, goals):
|
||||||
|
count = len(goals)
|
||||||
|
print(f'Account has {count} savings goal(s):')
|
||||||
|
for index, goal, in enumerate(goals, 1):
|
||||||
|
print(f'{index}. Savings Goal UID = {goal.savingsGoalUid}')
|
||||||
|
print(f' Name = {goal.name}')
|
||||||
|
if hasattr(goal, 'target'):
|
||||||
|
print(f' Target = {self.format_amount(goal.target)}')
|
||||||
|
print(f' Total Saved = {self.format_amount(goal.totalSaved)}')
|
||||||
|
if hasattr(goal, 'savedPercent'):
|
||||||
|
print(f' Saved Percent = {goal.savedPercent}')
|
||||||
|
print(f' State = {goal.state}')
|
||||||
|
|
||||||
|
def print_spending_spaces(self, spaces):
|
||||||
|
count = len(spaces)
|
||||||
|
print(f'Account has {count} spending space(s):')
|
||||||
|
for index, space, in enumerate(spaces, 1):
|
||||||
|
print(f'{index}. Spending Space UID = {space.spaceUid}')
|
||||||
|
print(f' Name = {space.name}')
|
||||||
|
print(f' Balance = {self.format_amount(space.balance)}')
|
||||||
|
print(f' Card Association UID = {space.cardAssociationUid}')
|
||||||
|
print(f' Type = {space.spendingSpaceType}')
|
||||||
|
print(f' State = {space.state}')
|
||||||
|
|
||||||
#-----------------------------------------------------------------------------#
|
#-----------------------------------------------------------------------------#
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -376,13 +427,15 @@ parser = argparse.ArgumentParser(
|
|||||||
payees - list the customer's payees
|
payees - list the customer's payees
|
||||||
payee add - adds a new payee
|
payee add - adds a new payee
|
||||||
payee del <payee_uid> - deletes an existing payee
|
payee del <payee_uid> - deletes an existing payee
|
||||||
account add <payee_uid> - adds a new account to a payee
|
account add - adds a new account to a payee
|
||||||
|
account del <account_uid> - removes an account from a payee
|
||||||
pay <account_uid> <amount> <ref> - pay an existing payee
|
pay <account_uid> <amount> <ref> - pay an existing payee
|
||||||
''')
|
''')
|
||||||
parser.add_argument('action', help='which action to perform', nargs='*')
|
parser.add_argument('action', help='which action to perform', nargs='*')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
client = StarlingClient()
|
script = os.path.realpath(sys.argv[0])
|
||||||
|
client = StarlingClient(os.path.dirname(script))
|
||||||
|
|
||||||
action_args = args.action
|
action_args = args.action
|
||||||
if len(action_args) == 0:
|
if len(action_args) == 0:
|
||||||
@@ -423,7 +476,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')
|
||||||
client.account_del(*actuon_args)
|
client.account_del(*action_args)
|
||||||
else:
|
else:
|
||||||
parser.error(f'Unknown "{action} {subaction}" action')
|
parser.error(f'Unknown "{action} {subaction}" action')
|
||||||
elif action == 'pay':
|
elif action == 'pay':
|
||||||
@@ -432,6 +485,10 @@ elif action == 'pay':
|
|||||||
if count > 3:
|
if count > 3:
|
||||||
parser.error(f'Too many arguments for "{action}" action')
|
parser.error(f'Too many arguments for "{action}" action')
|
||||||
client.pay(*action_args)
|
client.pay(*action_args)
|
||||||
|
elif action == 'spaces':
|
||||||
|
if count > 1:
|
||||||
|
parser.error(f'Too many arguments for "{action}" action')
|
||||||
|
client.list_spaces()
|
||||||
else:
|
else:
|
||||||
parser.error(f'Unknown action "{action}"')
|
parser.error(f'Unknown action "{action}"')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user