Created
March 24, 2018 19:48
-
-
Save thexs-dev/5a47f8cac1c3358f3a63a9bd100b4235 to your computer and use it in GitHub Desktop.
Google Apps Script web app listening to PayPal IPN messages for subscriptions management
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function doPost(e) { | |
try { | |
var sprops = backoff(function(){ | |
return PropertiesService.getScriptProperties().getProperties(); | |
}); | |
var base = FirebaseApp.getDatabaseByUrl(sprops.firebaseUrl, sprops.firebaseSecret); | |
var glog = new cLog("IpnPaypal", sprops.RECEIVER_EMAIL); // just for logging errors | |
var isProduction = sprops.Production == "true"; | |
var ipn = e.parameter; | |
base.pushData("~dumping/all", ipn); | |
base.pushData("~dumping/postdata", e.postData.contents); | |
// check this page https://www.paypal-knowledge.com/infocenter/index?page=content&widgetview=true&id=FAQ1916&viewlocale=en_US | |
// and change //www. with //ipnbp. ?? | |
var paypalUrl = isProduction ? "https://www.paypal.com/cgi-bin/webscr" : "https://www.sandbox.paypal.com/cgi-bin/webscr"; | |
var options = { | |
"method" : "post", | |
}; | |
//Handshake with PayPal - send acknowledgement and get VERIFIED or INVALID response | |
var resp = UrlFetchApp.fetch(paypalUrl + "?cmd=_notify-validate&" + e.postData.contents, options); // backoff?? | |
var payer_uid = ipn.payer_email ? ipn.payer_email.dot() : "~unknown"; | |
var payer_app = "unknown"; // unknown payer_app if no item_name or no match below | |
if (ipn.item_name) { | |
if ( ["Google Apps for Work subscription"].some(function(v) { return ipn.item_name.indexOf(v)>-1;}) ) payer_app = "GApps"; | |
else if ( ["xsDirectory App"].some(function(v) { return ipn.item_name.indexOf(v)>-1;}) ) payer_app = "Directory"; | |
// else if ... any other "old" app subscription not using the custom fields | |
} | |
// ipn.custom = (ipn.custom || "app=unknown&uid=~unknown").split("&").reduce(function(p,v){ | |
ipn.custom = (ipn.custom || "app=".concat(payer_app, "&uid=", payer_uid)).split("&").reduce(function(p,v){ | |
var pair = v.split("="); p[pair[0]] = pair[1]; return p; | |
}, {}); | |
ipn.path = [ipn.custom.app || payer_app, ipn.custom.uid || payer_uid].join("/"); | |
ipn.stamp = new Date().getTime(); | |
if (resp == 'VERIFIED') { | |
if (ipn.receiver_email.toLowerCase() == sprops.RECEIVER_EMAIL) { | |
// common actions for any IPN that gets here ... | |
var current = { | |
stamp: ipn.stamp, | |
} | |
// current.next_payment_date: calculate it on add-on server side based on current.period3 and current.payment_date (done there) | |
if (ipn.payer_email) current.payer_email = ipn.payer_email; | |
if (ipn.payment_date) current.payment_date = ipn.payment_date; | |
if (ipn.payment_status) current.payment_status = ipn.payment_status; | |
if (ipn.subscr_date) current.subscr_date = ipn.subscr_date; | |
if (ipn.subscr_id) current.subscr_id = ipn.subscr_id; | |
if (ipn.period3) current.period3 = ipn.period3; | |
if (ipn.txn_type) current.txn_type = ipn.txn_type; | |
if (ipn.txn_id) current.txn_id = ipn.txn_id; | |
if (ipn.mc_gross) current.mc_gross = ipn.mc_gross; | |
if (ipn.mc_fee) current.mc_fee = ipn.mc_fee; | |
if (ipn.mc_currency) current.mc_currency = ipn.mc_currency; | |
base.updateData([ipn.path, "current"].join("/"), current); | |
base.pushData([ipn.path, "history"].join("/"), ipn); | |
// add-on/app Subscribe form depends on active (subscribed flag) value (dom-if?), also set rule .read:true for IPN/$app/$uid/active | |
var active; | |
if (ipn.payment_status === "Completed") active = true; | |
if ( "Refunded".split(",").indexOf(ipn.payment_status) >-1 ) active = false; | |
if ("subscr_eot,recurring_payment_suspended?,recurring_payment_suspended_due_to_max_failed_payment".indexOf(ipn.txn_type)>-1) active = false; | |
if (active !== undefined) | |
base.setData([ipn.path, "active"].join("/"), active); | |
// reactivate a subcription, after a manual suspend in PP console does not send an ipn message, must set the active flag true manually | |
// Deal with actions/alerts for specific "critical" payment_status, subscr_, etc? | |
if ( ("Canceled_Reversal,Comple-ted,Declined,Denied,Expired,Failed,Partially_Refunded,Pending,Processed,Refunded,Reversed,Voided".indexOf(ipn.payment_status)>-1) | |
|| ("subscr_cancel,subscr_modify,subscr_failed,subscr_eot,recurring_payment_suspended_due_to_max_failed_payment".indexOf(ipn.txn_type)>-1) ) { | |
base.pushData("~actions", ipn); | |
} | |
} else { // Request did not originate from my PayPal account | |
base.pushData("~dumping/not-from-me", ipn); | |
} | |
} else { // PayPal response INVALID | |
base.pushData("~dumping/invalid", ipn); | |
} | |
} catch(e) { | |
glog.log(e, "doPostCallback"); | |
} | |
} | |
function backoff(func) { | |
var maxRetries = 4, sleepFor = 1000, waiting; | |
for (var n=0; ; n++) { | |
try { | |
return func(); | |
} catch(e) { | |
if (n >= maxRetries) throw e; | |
waiting = (Math.pow(2,n) * sleepFor) + (Math.round(Math.random() * sleepFor)); | |
Utilities.sleep(waiting); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
About PayPal IPN and Firebase (FB) integration for GAS subscriptions | |
Quick notes: | |
- I didn't use the IPN sandbox | |
- I just used the IPN simulator and after that I created a $1 weekly subscription for "live" test | |
- For the subscription link I use a PayPal subscription button, but manually including a IPN custom fields to identify the user and the add-n, like | |
custom=app=Mapping&uid=me@here | |
- You need to add a hidden field to the PayPal form like | |
<input type="hidden" name="custom" value="app=Mapping&uid=[[_user(subscription.user)]]"> | |
where _user() returns the UID (unique ID) associated with the user's email | |
- Your PayPal form might look like | |
<form action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_blank"> | |
<input type="hidden" name="cmd" value="_s-xclick"> | |
<input type="hidden" name="hosted_button_id" value="YGYHYYYTSVHB"> | |
<input type="image" src="https://www.paypalobjects.com/en_US/i/btn/btn_subscribeCC_LG.gif" border="0" name="submit" alt="PayPal - The safer, easier way to pay online!"> | |
<input type="hidden" name="custom" value="app=Mapping&uid=[[_user(subscription.user)]]"> | |
<img alt="" border="0" src="https://www.paypalobjects.com/en_US/i/scr/pixel.gif" width="1" height="1"> | |
</form> | |
- You will need a FB library / tool to access FB server side (like Romain or Bruce library) and client-side (like JS library or a Polymer element) | |
- Secure the FB database for only read access to the user's entry, with rules like this | |
"IPN": { | |
".read": false, | |
".write": false, | |
"$app": { | |
"$uid": { | |
".read": "$uid === auth.email", | |
".write": false, | |
"active": { | |
".read": true | |
}, | |
}, | |
}, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment