2022-11-22 12:58:29 -08:00
#!/usr/bin/python3
2022-02-08 09:28:35 +11:00
""" Download subscribers or subscriber transactions from PayPal.
Used to help Conservancy keep track of Sustainers .
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 ) .
2021-11-19 13:17:46 +11:00
Run it like this :
$ export PAYPAL_CLIENT_ID = XXX
$ export PAYPAL_CLIENT_SECRET = YYY
2021-11-26 15:31:20 +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 transactions 2021 - 11 - 01 T00 : 00 : 00 - 07 : 00 2021 - 11 - 30 T23 : 59 : 59 - 07 : 00 > transactions . txt
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
functionality from the PayPal importer and paypal_rest tools , which I wasn ' t
aware of at the time . See :
- conservancy_beancount / doc / PayPalQuery . md
- NPO - Accounting / paypal_rest
2021-11-19 13:17:46 +11:00
"""
import argparse
2021-11-26 15:15:12 +11:00
import collections
import datetime
2021-11-19 13:17:46 +11:00
import sys
import os
import requests
2021-11-26 15:15:12 +11:00
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 )
2022-10-28 13:39:56 +11:00
elif args . report == ' transactions ' :
2021-11-26 15:15:12 +11:00
report_on_transactions ( transactions )
2022-10-28 13:39:56 +11:00
else :
suspend_subscriptions_interactively ( transactions , args . pattern , access_token )
2021-11-26 15:15:12 +11:00
def parse_args ( ) :
parser = argparse . ArgumentParser ( description = ' Download PayPal subscriber info. ' )
2022-10-28 13:39:56 +11:00
parser . add_argument ( ' report ' , help = ' report to run " ' , choices = [ ' profiles ' , ' transactions ' , ' suspend ' ] )
2021-11-26 15:15:12 +11:00
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 )
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 ) :
2021-11-19 13:17:46 +11:00
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 ' ,
} ,
)
data = response . json ( )
return data [ ' access_token ' ]
2021-11-26 15:15:12 +11:00
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
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. """
2021-11-19 13:17:46 +11:00
print ( f ' Fetching transactions page { page } . ' , file = sys . stderr )
2021-11-26 15:15:12 +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 } '
2021-11-19 13:17:46 +11:00
response = requests . get (
url ,
headers = {
' Content-Type ' : ' application/json ' ,
' Authorization ' : f ' Bearer { access_token } ' ,
} ,
)
data = response . json ( )
if page < data [ ' total_pages ' ] :
2021-11-26 15:15:12 +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 :
print ( f ' Skipping transaction { transaction_info [ " transaction_id " ] } with no PayPal Reference ID. ' , file = sys . stderr )
2021-11-26 15:15:12 +11:00
for id , desc in sorted ( records ) :
print ( f ' { id } { desc } ' )
count = collections . Counter ( [ id for 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 )
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 ' ]
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 ' ] ,
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 :
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 )
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 )
for ( id , subject ) in subscriptions :
sub_before = paypal_request ( f ' https://api.paypal.com/v1/billing/subscriptions/ { id } ' , access_token )
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 ' ]
print ( f ' Subscriber { 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 '
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 )
status_after = sub_after [ ' status ' ]
print ( f ' Subscriber { id } status after: { 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
2021-11-26 15:15:12 +11:00
if __name__ == ' __main__ ' :
main ( )