Add better error checking of API requests, improve output messages and add usage documentation
This commit is contained in:
parent
6102d39759
commit
8cee02ca78
1 changed files with 184 additions and 107 deletions
291
paypal_report.py
291
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()
|
||||
|
|
Loading…
Reference in a new issue