I was working on a project where a subscription is needed to access parts of the website (drivingtest.cleverdodo.com). The idea was to have a Paypal button which would allow users to pay for 1 month subscription at a time and give them instant access. The standard button (the one you get from Paypal’s website) allows you to receive money but you’d have to manually check the payment and update the customer’s record to grant him access. If you wanted your system to be notified instantly, you’ll have to dive into Instant Payment Notification (IPN) and Payment Data Transfer (PDT).
Before I show you the C# codes for Paypal integration, you’ll need to understand what IPN and PDT are. IPN is a message service; when someone has made a payment to you, Paypal will send this message to your listener. It is the most reliable way to know whether you truly have received money because the message will be sent at some point or the other, whether the user does not return to your website after completing the payment or he loses internet connection or there’s congestion and the transaction notification is delayed. However you’ll need to have a listener to capture the message. PDT on the other hand happens when the user either clicks on the “Goc back to merchant website” link (your return url) or when the user is redirected to your website immediately after purchase. If for some reason or the other, the customer does not return to your website, you will receive no notification of a successful payment.
Therefore it is important that you have both IPN and PDT but the good news is that the codes for the two are not that much different after all.
Paypal Sandbox and the Developer account
You do not want to be testing against your live business/personal account. Therefore you’ll need to register for a developer account first to get access to Paypal Sandbox which allows you to test at your heart’s will.
Developer : https://developer.paypal.com/
Once registered, just create a test business account and a personal account so that you can check whether when you buy from the personal account, the money is transferred to your business account.
You’ll also need to sign in with your test business account to https://www.sandbox.paypal.com and go to Profile -> Website Payment Preferences and turn Auto Return and Payment Data Transfer to On so that you get an Identity Token to use for PDT. I had trouble finding my token because I was looking on the Developers url rather than the Sandbox one.
How it works in my project
When a user is logged in, he’s able to see a Paypal button in his dashboard (my account area). When he clicks the button, it takes him to an intermediate page which contains a form with all the necessary values to send off to Paypal. When this page is loaded, a record is created in the order table and the order id is inserted as a hidden form field. I need this value back from Paypal to update the corresponding user subscription record.
So this is an MVC view but the core principle remains the same for any other programming language
@{ ViewBag.Title = "Paypal Checkout"; } @section TestHeadContent { <meta name="robots" content="noindex" /> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js"></script> <script src="/scripts/jquery.countdown.min.js"></script> <script type="text/javascript"> $(document).ready(function () { $('#paypal-timer').countdown({ until: '+2s', format: 'MS', layout: '{sn}', onExpiry: SubmitTheForm }); }); function SubmitTheForm() { $('#paypalform').submit(); } </script> } <div id="area"> <div style="min-height:400px;"> <strong>Redirecting you to the Paypal website in <span id="paypal-timer"></span> second(s)...</strong> <form name="paypalform" id="paypalform" action="@ViewData["PaypalUrl"]" method="post" enctype="application/x-www-form-urlencoded"> <input type="hidden" name="cmd" value="_xclick" /> <input type="hidden" name="business" value="@System.Configuration.ConfigurationManager.AppSettings["Business"]" /> <input type="hidden" name="amount" value="4" /> <input type="hidden" name="currency_code" value="USD" /> <input type="hidden" name="notify_url" value="@String.Concat(System.Configuration.ConfigurationManager.AppSettings["WebsiteUrl"], "paypal/ipn")" /> <input type="hidden" name="cancel" value="@String.Concat(System.Configuration.ConfigurationManager.AppSettings["WebsiteUrl"], "paypal/cancel")" /> <input type="hidden" name="return" value="@String.Concat(System.Configuration.ConfigurationManager.AppSettings["WebsiteUrl"], "paypal/pdt")" /> <input type="hidden" name="item_name" value="1 Month Access" /> <input type="hidden" name="custom" value="@ViewData["OrderId"]" /> </form> </div> </div>The form is populated with values from the AppSettings; Business is your business email address and notify_url is the URL of the IPN listener. The form action is set to either the sandbox or live Paypal url depending on a setting in the web.config file. After 2 seconds, the form is automatically submitted.
Note : You cannot issue a post command from code behind (ie programmatically) because you will need to redirect the user to that same page afterwards for him to enter his details. Therefore you will lose your post data. That is why we’re using a form to post the values and send the users to Paypal.
Code for IPN
Usually this code will go into your business logic layer (thin controllers, fat models) but this is just to simplify things.
public ActionResult IPN() { log.Info("IPN listener invoked"); try { var formVals = new Dictionary<string, string>(); formVals.Add("cmd", "_notify-validate"); string response = GetPayPalResponse(formVals); if (response == "VERIFIED") { string transactionId = Request["txn_id"]; string sAmountPaid = Request["mc_gross"]; string orderId = Request["custom"]; //_logger.Info("IPN Verified for order " + orderID); //validate the order Decimal amountPaid = 0; Decimal.TryParse(sAmountPaid, out amountPaid); Order order = new Gis.Quiz.Core.Repository.Repository<Order>(_session).GetById(Convert.ToInt32(orderId)); // check these first bool verified = true; string businessEmail = HttpUtility.UrlDecode(Request["business"]); if (String.Compare(businessEmail, System.Configuration.ConfigurationManager.AppSettings["Business"], true) != 0) verified = false; string currencyCode = HttpUtility.UrlDecode(Request["mc_currency"]); if (String.Compare(currencyCode, "USD", true) != 0) verified = false; string paymentStatus = HttpUtility.UrlDecode(Request["payment_status"]); if (String.Compare(paymentStatus, "Completed", true) != 0) verified = false; if (!AmountPaidIsValid(order, amountPaid)) verified = false; log.Info("Business : " + businessEmail); log.Info("currency : " + currencyCode); log.Info("payment status : " + paymentStatus); log.Info("amount valid : " + AmountPaidIsValid(order, amountPaid).ToString()); // process the transaction if (verified) { log.Info("Trying to process ipn transaction..."); // check that we have not already processed this transaction if (String.Compare(order.TransactionId, transactionId, true) != 0) { order.Firstname = HttpUtility.UrlDecode(Request["first_name"]); order.Lastname = HttpUtility.UrlDecode(Request["last_name"]); order.Email = HttpUtility.UrlDecode(Request["payer_email"]); order.Street = HttpUtility.UrlDecode(Request["address_street"]); order.City = HttpUtility.UrlDecode(Request["address_city"]); order.State = HttpUtility.UrlDecode(Request["address_state"]); order.Country = HttpUtility.UrlDecode(Request["address_country"]); order.Zip = HttpUtility.UrlDecode(Request["address_zip"]); order.TransactionId = transactionId; order.StatusId = 1; order.UpdateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); UserSubscription us = new Gis.Quiz.Core.Repository.Repository<UserSubscription>(_session).GetById(order.UserId); if (us == null) { // create new subscription record us = new UserSubscription(); us.Id = order.UserId; us.CreateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); us.UpdateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); us.SubscriptionEndDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now.AddDays(30)); } else { // update subscription record us.UpdateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); us.SubscriptionEndDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now.AddDays(30)); } using (ITransaction tx = _session.BeginTransaction()) { _session.Save(order); // save order _session.Save(us); // save subscription tx.Commit(); } } } else { //let fail - this is the IPN so there is no viewer log.Info("ipn is not valid..."); } } else log.Info("ipn not verified..."); } catch (Exception ex) { log.Error("Error caught in IPN : " + ex.ToString()); } return View(); }You need to check you haven’t processed the transaction yet because you could end up doing it twice as Paypal could send you a notification through both PDT and IPN.
The PDT Code
public ActionResult PDT() { log.Info("PDT called..."); try { //_logger.Info("PDT Invoked"); string transactionId = Request.QueryString["tx"]; string sAmountPaid = Request.QueryString["amt"]; string orderId = Request.QueryString["cm"]; Dictionary<string, string> formVals = new Dictionary<string, string>(); formVals.Add("cmd", "_notify-synch"); formVals.Add("at", System.Configuration.ConfigurationManager.AppSettings["PayPalPDTToken"]); formVals.Add("tx", transactionId); string response = GetPayPalResponse(formVals); //_logger.Info("PDT Response received: " + response); if (response.StartsWith("SUCCESS")) { //_logger.Info("PDT Response received for order " + orderID); //validate the order Decimal amountPaid = 0; Decimal.TryParse(sAmountPaid, out amountPaid); Order order = new Gis.Quiz.Core.Repository.Repository<Order>(_session).GetById(Convert.ToInt32(orderId)); //check the amount paid if (AmountPaidIsValid(order, amountPaid)) { // check that we have not already processed this transaction if (String.Compare(order.TransactionId, transactionId, true) != 0) { order.Firstname = GetPDTValue(response, "first_name"); order.Lastname = GetPDTValue(response, "last_name"); order.Email = GetPDTValue(response, "payer_email"); order.Street = GetPDTValue(response, "address_street"); order.City = GetPDTValue(response, "address_city"); order.State = GetPDTValue(response, "address_state"); order.Country = GetPDTValue(response, "address_country"); order.Zip = GetPDTValue(response, "address_zip"); order.TransactionId = transactionId; order.StatusId = 1; order.UpdateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); UserSubscription us = new Gis.Quiz.Core.Repository.Repository<UserSubscription>(_session).GetById(order.UserId); if (us == null) { // create new subscription record us = new UserSubscription(); us.Id = order.UserId; us.CreateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); us.UpdateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); us.SubscriptionEndDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now.AddDays(30)); } else { // update subscription record us.UpdateDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now); us.SubscriptionEndDate = Gis.Quiz.Core.Utilities.Helper.FormatNHibernateDateTime(DateTime.Now.AddDays(30)); } using (ITransaction tx = _session.BeginTransaction()) { _session.Save(order); // save order _session.Save(us); // save subscription tx.Commit(); } ViewData["fb"] = "Thank you for your payment. Your transaction has been completed and a receipt for your purchase has been emailed to you. You can now access the paid members area of our website by <a href=\"/myaccount\">clicking here</a>."; } else { ViewData["fb"] = "This order has already been processed."; } } else { //Payment amount is off, this can happen if you have a Gift cert at PayPal, be careful of this! ViewData["fb"] = "Payment amount received from paypal does not match order total."; } } else { ViewData["fb"] = "Your payment was not successful with PayPal"; } } catch (Exception ex) { log.Error("Error caught in PDT : " + ex.ToString()); } return View(); }PayPalPDTToken is the Identity Token you got in an earlier step above.
The helper methods
/// <summary> /// Utility method for handling PayPal Responses /// </summary> string GetPayPalResponse(Dictionary<string, string> formVals) { bool useSandbox = Convert.ToBoolean(System.Configuration.ConfigurationManager.AppSettings["IsLocal"]); string paypalUrl = useSandbox ? "https://www.sandbox.paypal.com/cgi-bin/webscr" : "https://www.paypal.com/cgi-bin/webscr"; HttpWebRequest req = (HttpWebRequest)WebRequest.Create(paypalUrl); // Set values for the request back req.Method = "POST"; req.ContentType = "application/x-www-form-urlencoded"; byte[] param = Request.BinaryRead(Request.ContentLength); string strRequest = Encoding.ASCII.GetString(param); StringBuilder sb = new StringBuilder(); sb.Append(strRequest); foreach (string key in formVals.Keys) { sb.AppendFormat("&{0}={1}", key, formVals[key]); } strRequest += sb.ToString(); req.ContentLength = strRequest.Length; //for proxy //WebProxy proxy = new WebProxy(new Uri("http://urlort#"); //req.Proxy = proxy; //Send the request to PayPal and get the response string response = ""; using (StreamWriter streamOut = new StreamWriter(req.GetRequestStream(), System.Text.Encoding.ASCII)) { streamOut.Write(strRequest); streamOut.Close(); using (StreamReader streamIn = new StreamReader(req.GetResponse().GetResponseStream())) { response = streamIn.ReadToEnd(); } } return response; } bool AmountPaidIsValid(Order order, decimal amountPaid) { bool result = true; if (order != null) { if (order.Total > amountPaid) { //_logger.Warn("Invalid order amount to PDT/IPN: " + order.ID + "; Actual: " + amountPaid.ToString("C") + "; Should be: " + order.Total.ToString("C") + "user IP is " + Request.UserHostAddress); result = false; } } else { //_logger.Warn("Invalid order ID passed to PDT/IPN; user IP is " + Request.UserHostAddress); } return result; } string GetPDTValue(string pdt, string key) { string[] keys = pdt.Split('\n'); string thisVal = ""; string thisKey = ""; foreach (string s in keys) { string[] bits = s.Split('='); if (bits.Length > 1) { thisVal = HttpUtility.UrlDecode(bits[1]); // values are urlencoded, not htmlencoded thisKey = bits[0]; if (thisKey.Equals(key, StringComparison.InvariantCultureIgnoreCase)) break; } } return thisVal; }Important things to remember
Here are some thing worth noting:
- You need to be logged into your sandbox account on the same browser that you’re sending off the request to Paypal to make a sucessful transaction in testing mode.
- You can only test IPN on a URL that’s publicly accessible. So localhost will not work.
Paypal integration is not complicated but I had to read lots on different forums/blogs and StackOverflow and I’m grateful to Kona.Web from which I’ve borrowed a lot of code.