Compare commits

...

3 Commits

Author SHA1 Message Date
41afddfaff Can now be run from any directory 2024-11-03 01:17:10 +00:00
2a4aff75f0 Implemented "account del" action 2023-09-17 22:47:37 +00:00
0a26671068 Added "spaces" action 2023-09-17 22:14:57 +00:00

107
starling
View File

@@ -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}"')