Skip to content

Instantly share code, notes, and snippets.

@thexs-dev
Created March 24, 2018 19:48
Show Gist options
  • Save thexs-dev/5a47f8cac1c3358f3a63a9bd100b4235 to your computer and use it in GitHub Desktop.
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
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);
}
}
}
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