Add better error checking of API requests, improve output messages and add usage documentation

This commit is contained in:
Ben Sturmfels 2022-12-17 14:03:04 +11:00
parent 6102d39759
commit 8cee02ca78
Signed by: bsturmfels
GPG key ID: 023C05E2C9C068F0

View file

@ -1,19 +1,33 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Download subscribers or subscriber transactions from PayPal. """Download or cancel subscribers or subscriber transactions from PayPal."""
Used to help Conservancy keep track of Sustainers. import argparse
import collections
import datetime
import os
import sys
Prints out either a list of recurring payment transactions or a list of unique import requests
recurring payment subscriber profiles. This recreates the features available in
the previous PayPal SOAP API where you could query for all ProfileIDs
(identifying unique subscribers).
Run it like this: HELP = """This tool is used to help Conservancy keep track of Sustainers.
With the "transactions" or "profiles" commands, this tool reports either a list
of recurring payment transactions or a list of unique recurring payment
subscriber profiles respectively. This recreates the features available in the
previous PayPal SOAP API where you could query for all ProfileIDs (identifying
unique subscribers).
With the "suspend" command, this tool will find recurring subscriptions matching
a given transaction subject, and interactively prompt you to review and suspend
them.
Run them like this:
$ export PAYPAL_CLIENT_ID=XXX $ export PAYPAL_CLIENT_ID=XXX
$ export PAYPAL_CLIENT_SECRET=YYY $ export PAYPAL_CLIENT_SECRET=YYY
$ python3 paypal_report.py profiles 2021-11-01T00:00:00-07:00 2021-11-30T23:59:59-07:00 > profiles.txt
$ python3 paypal_report.py transactions 2021-11-01T00:00:00-07:00 2021-11-30T23:59:59-07:00 > transactions.txt $ python3 paypal_report.py transactions 2021-11-01T00:00:00-07:00 2021-11-30T23:59:59-07:00 > transactions.txt
$ python3 paypal_report.py profiles 2021-11-01T00:00:00-07:00 2021-11-30T23:59:59-07:00 > profiles.txt
$ python3 paypal_report.py suspend 2022-10-01T00:00:00-07:00 2022-10-31T23:59:59-07:00 homebrew
NOTE: Newly created PayPal "apps"/credentials initially authenticate, but can NOTE: Newly created PayPal "apps"/credentials initially authenticate, but can
then return PERMISSION_DENIED for ~ 40 mins. then return PERMISSION_DENIED for ~ 40 mins.
@ -22,61 +36,20 @@ The PayPal OAuth 2.0 client ID and secret can be obtained from the Developer
Dashboard as described here: https://developer.paypal.com/api/rest/. Dashboard as described here: https://developer.paypal.com/api/rest/.
This tool isn't directly related Beancount, but it may duplicate some This tool isn't directly related Beancount, but it may duplicate some
functionality from the PayPal importer and paypal_rest tools, which I wasn't functionality from the PayPal importer and paypal_rest tools. See also:
aware of at the time. See:
- conservancy_beancount/doc/PayPalQuery.md - conservancy_beancount/doc/PayPalQuery.md
- NPO-Accounting/paypal_rest - NPO-Accounting/paypal_rest
""" """
import argparse
import collections
import datetime
import sys
import os
import requests HTTP_TIMEOUT = 10 # seconds
def main(): class PayPalException(Exception):
args = parse_args() pass
client_id, client_secret = load_paypal_keys()
access_token = get_paypal_access_token(client_id, client_secret)
transactions = get_all_transactions(access_token, args.start_date, args.end_date)
if args.report == 'profiles':
report_on_unique_profiles(transactions)
elif args.report == 'transactions':
report_on_transactions(transactions)
else:
suspend_subscriptions_interactively(transactions, args.pattern, access_token)
def parse_args(): def paypal_login(client_id, client_secret):
parser = argparse.ArgumentParser(description='Download PayPal subscriber info.')
parser.add_argument('report', help='report to run"', choices=['profiles', 'transactions', 'suspend'])
parser.add_argument('start_date', help='start date inclusive (eg. 2021-11-01T00:00:00-07:00)', type=parse_iso_time)
parser.add_argument('end_date', help='end date inclusive (eg. 2021-11-30T23:59:59-07:00)', type=parse_iso_time)
parser.add_argument('pattern')
return parser.parse_args()
def parse_iso_time(time_str):
try:
time = datetime.datetime.fromisoformat(time_str)
except ValueError:
raise argparse.ArgumentTypeError('Date did not match ISO format eg. 2021-11-01T00:00:00-07:00')
return time
def load_paypal_keys():
try:
client_id = os.environ['PAYPAL_CLIENT_ID']
client_secret = os.environ['PAYPAL_CLIENT_SECRET']
except KeyError:
sys.exit('Please set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables.')
return client_id, client_secret
def get_paypal_access_token(client_id, client_secret):
url = 'https://api-m.paypal.com/v1/oauth2/token?grant_type=client_credentials' url = 'https://api-m.paypal.com/v1/oauth2/token?grant_type=client_credentials'
response = requests.post( response = requests.post(
url, url,
@ -85,16 +58,116 @@ def get_paypal_access_token(client_id, client_secret):
'Accept': 'application/json', 'Accept': 'application/json',
'Accept-Language': 'en_US', 'Accept-Language': 'en_US',
}, },
timeout=HTTP_TIMEOUT,
) )
data = response.json() data = response.json()
if response.status_code != 200:
raise PayPalException(f'PayPal login failed: \"{data["error_description"]}\"')
print('Successfully logged in to PayPal.')
return data['access_token'] return data['access_token']
def paypal_get(url, access_token):
response = requests.get(
url,
headers={
'Accept': 'application/json',
'Accept-Language': 'en_US',
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}',
},
timeout=HTTP_TIMEOUT,
)
data = response.json()
if response.status_code != 200:
raise PayPalException(f'PayPal request failed: \"{data["message"]}\"')
return data
def paypal_post(url, access_token):
response = requests.post(
url,
headers={
'Accept': 'application/json',
'Accept-Language': 'en_US',
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}',
},
json={
'reason': 'suspended',
},
timeout=HTTP_TIMEOUT,
)
if response.status_code != 204:
data = response.json()
raise PayPalException(f'PayPal update failed: \"{data["message"]}\"')
return response
def main():
args = parse_args()
client_id, client_secret = load_paypal_keys()
try:
access_token = paypal_login(client_id, client_secret)
transactions = get_all_transactions(access_token, args.start_date, args.end_date)
if args.report == 'profiles':
report_on_unique_profiles(transactions)
elif args.report == 'transactions':
report_on_transactions(transactions)
else:
suspend_subscriptions_interactively(transactions, args.pattern, access_token)
except PayPalException as e:
sys.exit(e)
def parse_args():
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description="""Download or cancel subscribers or subscriber transactions from PayPal.""",
epilog=HELP,
)
parser.add_argument(
'report', help='report or action"', choices=['profiles', 'transactions', 'suspend']
)
parser.add_argument(
'start_date',
help='start date inclusive (eg. 2021-11-01T00:00:00-07:00)',
type=parse_iso_time,
)
parser.add_argument(
'end_date',
help='end date inclusive (eg. 2021-11-30T23:59:59-07:00)',
type=parse_iso_time,
)
parser.add_argument('pattern')
return parser.parse_args()
def parse_iso_time(time_str):
try:
time = datetime.datetime.fromisoformat(time_str)
except ValueError as e:
raise argparse.ArgumentTypeError(
'Date did not match ISO format eg. 2021-11-01T00:00:00-07:00'
) from e
return time
def load_paypal_keys():
try:
client_id = os.environ['PAYPAL_CLIENT_ID']
client_secret = os.environ['PAYPAL_CLIENT_SECRET']
except KeyError:
sys.exit(
'Please set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables.'
)
return client_id, client_secret
def get_all_transactions(access_token, start_date, end_date): def get_all_transactions(access_token, start_date, end_date):
periods = get_time_periods(start_date, end_date) periods = get_time_periods(start_date, end_date)
transactions = [] transactions = []
for start, end in periods: for start, end in periods:
print(f'Period {start} - {end}.', file=sys.stderr)
transactions += get_transactions_for_period(access_token, start, end) transactions += get_transactions_for_period(access_token, start, end)
return transactions return transactions
@ -113,18 +186,16 @@ def get_time_periods(start_date, end_date):
def get_transactions_for_period(access_token, start_date, end_date, page=1): def get_transactions_for_period(access_token, start_date, end_date, page=1):
"""Downloads all pages of transactions for a time period of 31 days or less.""" """Downloads all pages of transactions for a time period of 31 days or less."""
print(f'Fetching transactions page {page}.', file=sys.stderr) print(
url = f'https://api-m.paypal.com/v1/reporting/transactions?start_date={start_date.isoformat()}&end_date={end_date.isoformat()}&fields=all&page_size=500&page={page}' f'Requesting page {page} of transactions from {start_date} to {end_date}...',
response = requests.get( file=sys.stderr,
url,
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}',
},
) )
data = response.json() url = f'https://api-m.paypal.com/v1/reporting/transactions?start_date={start_date.isoformat()}&end_date={end_date.isoformat()}&fields=all&page_size=500&page={page}'
data = paypal_get(url, access_token)
if page < data['total_pages']: if page < data['total_pages']:
return data['transaction_details'] + get_transactions_for_period(access_token, start_date, end_date, page + 1) return data['transaction_details'] + get_transactions_for_period(
access_token, start_date, end_date, page + 1
)
else: else:
return data['transaction_details'] return data['transaction_details']
@ -147,11 +218,14 @@ def report_on_unique_profiles(transactions):
), ),
) )
else: else:
print(f'Skipping transaction {transaction_info["transaction_id"]} with no PayPal Reference ID.', file=sys.stderr) print(
f'Skipping transaction {transaction_info["transaction_id"]} with no PayPal Reference ID.',
file=sys.stderr,
)
for id, desc in sorted(records): for ref_id, desc in sorted(records):
print(f'{id} {desc}') print(f'{ref_id} {desc}')
count = collections.Counter([id for id, _ in records]) count = collections.Counter([ref_id for ref_id, _ in records])
dupes = [id for id, total in count.items() if total > 1] dupes = [id for id, total in count.items() if total > 1]
print(f'Reference IDs with changing subject: {dupes}', file=sys.stderr) print(f'Reference IDs with changing subject: {dupes}', file=sys.stderr)
@ -160,7 +234,11 @@ def get_subscriptions_matching_subject(transactions, pattern):
records = set() records = set()
for t in transactions: for t in transactions:
transaction_info = t['transaction_info'] transaction_info = t['transaction_info']
if 'paypal_reference_id' in transaction_info and pattern.lower() in transaction_info.get('transaction_subject', 'CANT MATCH ME').lower(): if (
'paypal_reference_id' in transaction_info
and pattern.lower()
in transaction_info.get('transaction_subject', 'CANT MATCH ME').lower()
):
records.add( records.add(
( (
transaction_info['paypal_reference_id'], transaction_info['paypal_reference_id'],
@ -178,16 +256,36 @@ def report_on_transactions(transactions):
for t in transactions: for t in transactions:
transaction_info = t['transaction_info'] transaction_info = t['transaction_info']
if 'paypal_reference_id' in transaction_info: if 'paypal_reference_id' in transaction_info:
print("%-19s %s" % (transaction_info['transaction_id'], transaction_info['paypal_reference_id'])) print(
print("%-19s %s" % (transaction_info['paypal_reference_id'], transaction_info['paypal_reference_id'])) "%-19s %s"
% (
transaction_info['transaction_id'],
transaction_info['paypal_reference_id'],
)
)
print(
"%-19s %s"
% (
transaction_info['paypal_reference_id'],
transaction_info['paypal_reference_id'],
)
)
else: else:
print(f'Skipping transaction {transaction_info["transaction_id"]} with no PayPal Reference ID.', file=sys.stderr) print(
f'Skipping transaction {transaction_info["transaction_id"]} with no PayPal Reference ID.',
file=sys.stderr,
)
def suspend_subscriptions_interactively(transactions, pattern, access_token): def suspend_subscriptions_interactively(transactions, pattern, access_token):
subscriptions = get_subscriptions_matching_subject(transactions, pattern) subscriptions = get_subscriptions_matching_subject(transactions, pattern)
for (id, subject) in subscriptions: for (sub_id, subject) in sorted(subscriptions):
sub_before = paypal_request(f'https://api.paypal.com/v1/billing/subscriptions/{id}', access_token) try:
sub_before = paypal_get(
f'https://api.paypal.com/v1/billing/subscriptions/{sub_id}', access_token
)
except PayPalException as e:
sys.exit(e)
status_before = sub_before['status'] status_before = sub_before['status']
# Donor name / total donations / last donation / donation amount / FULL text of the profile Memo description / profile-id " with "Should I cancel this one? (y/n)" # Donor name / total donations / last donation / donation amount / FULL text of the profile Memo description / profile-id " with "Should I cancel this one? (y/n)"
@ -199,44 +297,23 @@ def suspend_subscriptions_interactively(transactions, pattern, access_token):
last_payment_total = last_payment['amount']['value'] last_payment_total = last_payment['amount']['value']
last_payment_currency = last_payment['amount']['currency_code'] last_payment_currency = last_payment['amount']['currency_code']
last_payment_time = last_payment['time'] last_payment_time = last_payment['time']
print(f'Subscriber {id} {subject} {status_before}: {name}, {payments} payments with last payment {last_payment_time}, {last_payment_total} {last_payment_currency}') print(
f'Subscriber {sub_id} {subject} {status_before}: {name}, {payments} payments with last payment {last_payment_time}, {last_payment_total} {last_payment_currency}'
)
if status_before == 'ACTIVE': if status_before == 'ACTIVE':
url = f'https://api.paypal.com/v1/billing/subscriptions/{id}/suspend' url = f'https://api.paypal.com/v1/billing/subscriptions/{sub_id}/suspend'
if input('Cancel this one? (y/n) ') == 'y': if input('Cancel this one? (y/n) ') == 'y':
response = paypal_post_request(url, access_token) print(f'Updating subscription {sub_id}...')
print(f'PayPal said: {response.text}') paypal_post(url, access_token)
sub_after = paypal_request(f'https://api.paypal.com/v1/billing/subscriptions/{id}', access_token) sub_after = paypal_get(
f'https://api.paypal.com/v1/billing/subscriptions/{sub_id}',
access_token,
)
status_after = sub_after['status'] status_after = sub_after['status']
print(f'Subscriber {id} status after: {status_after}') print(f'Subscriber {sub_id} status is now: {status_after}')
else: else:
print(' ignoring inactive status') print(' ignoring inactive status')
def paypal_request(url, access_token):
response = requests.get(
url,
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}',
},
)
data = response.json()
return data
def paypal_post_request(url, access_token):
response = requests.post(
url,
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}',
},
json={
'reason': 'suspended',
},
)
return response
if __name__ == '__main__': if __name__ == '__main__':
main() main()