2022-11-22 12:58:29 -08:00
#!/usr/bin/python3
2022-12-17 14:03:04 +11:00
""" Download or cancel subscribers or subscriber transactions from PayPal. """
2022-02-08 09:28:35 +11:00
2022-12-17 14:03:04 +11:00
import argparse
import collections
import datetime
import os
import sys
import requests
HELP = """ This tool is used to help Conservancy keep track of Sustainers.
2022-02-08 09:28:35 +11:00
2022-12-17 14:03:04 +11:00
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 ) .
2021-11-19 13:17:46 +11:00
2022-12-17 14:03:04 +11:00
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 :
2021-11-19 13:17:46 +11:00
$ export PAYPAL_CLIENT_ID = XXX
$ export PAYPAL_CLIENT_SECRET = YYY
2021-11-26 15:31:20 +11:00
$ python3 paypal_report . py transactions 2021 - 11 - 01 T00 : 00 : 00 - 07 : 00 2021 - 11 - 30 T23 : 59 : 59 - 07 : 00 > transactions . txt
2022-12-17 14:03:04 +11:00
$ python3 paypal_report . py profiles 2021 - 11 - 01 T00 : 00 : 00 - 07 : 00 2021 - 11 - 30 T23 : 59 : 59 - 07 : 00 > profiles . txt
$ python3 paypal_report . py suspend 2022 - 10 - 01 T00 : 00 : 00 - 07 : 00 2022 - 10 - 31 T23 : 59 : 59 - 07 : 00 homebrew
2021-11-19 13:17:46 +11:00
2022-10-28 13:36:15 +11:00
NOTE : Newly created PayPal " apps " / credentials initially authenticate , but can
then return PERMISSION_DENIED for ~ 40 mins .
2022-02-08 09:28:35 +11:00
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
2022-12-17 14:03:04 +11:00
functionality from the PayPal importer and paypal_rest tools . See also :
2022-02-08 09:28:35 +11:00
- conservancy_beancount / doc / PayPalQuery . md
- NPO - Accounting / paypal_rest
2021-11-19 13:17:46 +11:00
"""
2022-12-17 14:03:04 +11:00
HTTP_TIMEOUT = 10 # seconds
class PayPalException ( Exception ) :
pass
def paypal_login ( client_id , client_secret ) :
url = ' https://api-m.paypal.com/v1/oauth2/token?grant_type=client_credentials '
response = requests . post (
url ,
auth = ( client_id , client_secret ) ,
headers = {
' 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
2021-11-19 13:17:46 +11:00
2021-11-26 15:15:12 +11:00
def main ( ) :
args = parse_args ( )
client_id , client_secret = load_paypal_keys ( )
2022-12-17 14:03:04 +11:00
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 )
2021-11-26 15:15:12 +11:00
def parse_args ( ) :
2022-12-17 14:03:04 +11:00
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 ,
)
2022-10-28 13:39:56 +11:00
parser . add_argument ( ' pattern ' )
2021-11-26 15:15:12 +11:00
return parser . parse_args ( )
def parse_iso_time ( time_str ) :
try :
time = datetime . datetime . fromisoformat ( time_str )
2022-12-17 14:03:04 +11:00
except ValueError as e :
raise argparse . ArgumentTypeError (
' Date did not match ISO format eg. 2021-11-01T00:00:00-07:00 '
) from e
2021-11-26 15:15:12 +11:00
return time
def load_paypal_keys ( ) :
try :
client_id = os . environ [ ' PAYPAL_CLIENT_ID ' ]
client_secret = os . environ [ ' PAYPAL_CLIENT_SECRET ' ]
except KeyError :
2022-12-17 14:03:04 +11:00
sys . exit (
' Please set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables. '
)
2021-11-26 15:15:12 +11:00
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 :
transactions + = get_transactions_for_period ( access_token , start , end )
return transactions
def get_time_periods ( start_date , end_date ) :
""" Break a time period into smaller periods of up to 31 days.
PayPal only supports querying for up to 31 days of transactions at a time .
"""
periods = [ ]
while start_date < end_date :
periods . append ( ( start_date , start_date + datetime . timedelta ( days = 31 ) ) )
start_date + = datetime . timedelta ( days = 31 )
return periods
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. """
2022-12-17 14:03:04 +11:00
print (
f ' Requesting page { page } of transactions from { start_date } to { end_date } ... ' ,
file = sys . stderr ,
2021-11-19 13:17:46 +11:00
)
2022-12-17 14:03:04 +11:00
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 )
2021-11-19 13:17:46 +11:00
if page < data [ ' total_pages ' ] :
2022-12-17 14:03:04 +11:00
return data [ ' transaction_details ' ] + get_transactions_for_period (
access_token , start_date , end_date , page + 1
)
2021-11-19 13:17:46 +11:00
else :
return data [ ' transaction_details ' ]
2021-11-26 15:15:12 +11:00
def report_on_unique_profiles ( transactions ) :
2022-02-08 09:28:35 +11:00
""" Print a list of subscribers from a set of transactions.
PayPal doesn ' t provide a way to query for subscribers directly, so we build
this by scanning through the list of transactions and finding the unique
subscribers .
"""
2021-11-26 15:15:12 +11:00
records = set ( )
2021-11-19 13:17:46 +11:00
for t in transactions :
transaction_info = t [ ' transaction_info ' ]
if ' paypal_reference_id ' in transaction_info :
2021-11-26 15:15:12 +11:00
records . add (
2021-11-19 13:17:46 +11:00
(
transaction_info [ ' paypal_reference_id ' ] ,
2021-11-26 15:15:12 +11:00
transaction_info . get ( ' transaction_subject ' , ' NO TRANSACTION SUBJECT ' ) ,
2021-11-19 13:17:46 +11:00
) ,
)
else :
2022-12-17 14:03:04 +11:00
print (
f ' Skipping transaction { transaction_info [ " transaction_id " ] } with no PayPal Reference ID. ' ,
file = sys . stderr ,
)
2021-11-26 15:15:12 +11:00
2022-12-17 14:03:04 +11:00
for ref_id , desc in sorted ( records ) :
print ( f ' { ref_id } { desc } ' )
count = collections . Counter ( [ ref_id for ref_id , _ in records ] )
2021-11-26 15:15:12 +11:00
dupes = [ id for id , total in count . items ( ) if total > 1 ]
print ( f ' Reference IDs with changing subject: { dupes } ' , file = sys . stderr )
2022-10-28 13:39:56 +11:00
def get_subscriptions_matching_subject ( transactions , pattern ) :
records = set ( )
for t in transactions :
transaction_info = t [ ' transaction_info ' ]
2022-12-17 14:03:04 +11:00
if (
' paypal_reference_id ' in transaction_info
and pattern . lower ( )
in transaction_info . get ( ' transaction_subject ' , ' CANT MATCH ME ' ) . lower ( )
) :
2022-10-28 13:39:56 +11:00
records . add (
(
transaction_info [ ' paypal_reference_id ' ] ,
transaction_info . get ( ' transaction_subject ' , ' NO TRANSACTION SUBJECT ' ) ,
) ,
)
else :
# print(f'Skipping transaction {transaction_info["transaction_id"]} with no PayPal Reference ID.', file=sys.stderr)
pass
return records
2021-11-26 15:15:12 +11:00
def report_on_transactions ( transactions ) :
2022-02-08 09:28:35 +11:00
""" Print a formatted list of transactions. """
2021-11-26 15:15:12 +11:00
for t in transactions :
transaction_info = t [ ' transaction_info ' ]
if ' paypal_reference_id ' in transaction_info :
2022-12-17 14:03:04 +11:00
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 ' ] ,
)
)
2021-11-26 15:15:12 +11:00
else :
2022-12-17 14:03:04 +11:00
print (
f ' Skipping transaction { transaction_info [ " transaction_id " ] } with no PayPal Reference ID. ' ,
file = sys . stderr ,
)
2021-11-26 15:15:12 +11:00
2021-11-26 15:31:20 +11:00
2022-10-28 13:39:56 +11:00
def suspend_subscriptions_interactively ( transactions , pattern , access_token ) :
subscriptions = get_subscriptions_matching_subject ( transactions , pattern )
2022-12-17 14:03:04 +11:00
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 )
2022-10-28 13:39:56 +11:00
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)"
given_name = sub_before [ ' subscriber ' ] . get ( ' name ' , { } ) . get ( ' given_name ' , ' ' )
surname = sub_before [ ' subscriber ' ] . get ( ' name ' , { } ) . get ( ' surname ' , ' ' )
name = ' ' . join ( filter ( bool , [ given_name , surname ] ) )
payments = sub_before [ ' billing_info ' ] [ ' cycle_executions ' ] [ 0 ] [ ' cycles_completed ' ]
last_payment = sub_before [ ' billing_info ' ] [ ' last_payment ' ]
last_payment_total = last_payment [ ' amount ' ] [ ' value ' ]
last_payment_currency = last_payment [ ' amount ' ] [ ' currency_code ' ]
last_payment_time = last_payment [ ' time ' ]
2022-12-17 14:03:04 +11:00
print (
f ' Subscriber { sub_id } { subject } { status_before } : { name } , { payments } payments with last payment { last_payment_time } , { last_payment_total } { last_payment_currency } '
)
2022-10-28 13:39:56 +11:00
if status_before == ' ACTIVE ' :
2022-12-17 14:03:04 +11:00
url = f ' https://api.paypal.com/v1/billing/subscriptions/ { sub_id } /suspend '
2022-10-28 13:39:56 +11:00
if input ( ' Cancel this one? (y/n) ' ) == ' y ' :
2022-12-17 14:03:04 +11:00
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 ,
)
2022-10-28 13:39:56 +11:00
status_after = sub_after [ ' status ' ]
2022-12-17 14:03:04 +11:00
print ( f ' Subscriber { sub_id } status is now: { status_after } ' )
2022-10-28 13:39:56 +11:00
else :
print ( ' ignoring inactive status ' )
2021-11-26 15:15:12 +11:00
if __name__ == ' __main__ ' :
main ( )