diff --git a/paypal_report.py b/paypal_report.py index f66e31e..4bda855 100755 --- a/paypal_report.py +++ b/paypal_report.py @@ -1,19 +1,33 @@ #!/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 -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). +import requests -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_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 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 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/. 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 -aware of at the time. See: +functionality from the PayPal importer and paypal_rest tools. See also: - conservancy_beancount/doc/PayPalQuery.md - NPO-Accounting/paypal_rest """ -import argparse -import collections -import datetime -import sys -import os -import requests +HTTP_TIMEOUT = 10 # seconds -def main(): - args = parse_args() - 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) +class PayPalException(Exception): + pass -def parse_args(): - 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): +def paypal_login(client_id, client_secret): url = 'https://api-m.paypal.com/v1/oauth2/token?grant_type=client_credentials' response = requests.post( url, @@ -85,16 +58,116 @@ def get_paypal_access_token(client_id, client_secret): 'Accept': 'application/json', 'Accept-Language': 'en_US', }, + timeout=HTTP_TIMEOUT, ) 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'] +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): periods = get_time_periods(start_date, end_date) transactions = [] for start, end in periods: - print(f'Period {start} - {end}.', file=sys.stderr) transactions += get_transactions_for_period(access_token, start, end) 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): """Downloads all pages of transactions for a time period of 31 days or less.""" - print(f'Fetching transactions page {page}.', file=sys.stderr) - 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}' - response = requests.get( - url, - headers={ - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {access_token}', - }, + print( + f'Requesting page {page} of transactions from {start_date} to {end_date}...', + file=sys.stderr, ) - 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']: - 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: return data['transaction_details'] @@ -147,11 +218,14 @@ def report_on_unique_profiles(transactions): ), ) 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): - print(f'{id} {desc}') - count = collections.Counter([id for id, _ in records]) + for ref_id, desc in sorted(records): + print(f'{ref_id} {desc}') + count = collections.Counter([ref_id for ref_id, _ in records]) dupes = [id for id, total in count.items() if total > 1] print(f'Reference IDs with changing subject: {dupes}', file=sys.stderr) @@ -160,7 +234,11 @@ def get_subscriptions_matching_subject(transactions, pattern): records = set() for t in transactions: 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( ( transaction_info['paypal_reference_id'], @@ -178,16 +256,36 @@ def report_on_transactions(transactions): for t in transactions: transaction_info = t['transaction_info'] if 'paypal_reference_id' in transaction_info: - print("%-19s %s" % (transaction_info['transaction_id'], transaction_info['paypal_reference_id'])) - print("%-19s %s" % (transaction_info['paypal_reference_id'], transaction_info['paypal_reference_id'])) + print( + "%-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: - 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): subscriptions = get_subscriptions_matching_subject(transactions, pattern) - for (id, subject) in subscriptions: - sub_before = paypal_request(f'https://api.paypal.com/v1/billing/subscriptions/{id}', access_token) + for (sub_id, subject) in sorted(subscriptions): + 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'] # 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_currency = last_payment['amount']['currency_code'] 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': - 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': - response = paypal_post_request(url, access_token) - print(f'PayPal said: {response.text}') - sub_after = paypal_request(f'https://api.paypal.com/v1/billing/subscriptions/{id}', access_token) + print(f'Updating subscription {sub_id}...') + paypal_post(url, access_token) + sub_after = paypal_get( + f'https://api.paypal.com/v1/billing/subscriptions/{sub_id}', + access_token, + ) status_after = sub_after['status'] - print(f'Subscriber {id} status after: {status_after}') + print(f'Subscriber {sub_id} status is now: {status_after}') else: 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__': main()