Skip to content

Instantly share code, notes, and snippets.

@mslinn
Forked from alpeb/Paypal.scala
Last active June 9, 2017 22:22
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save mslinn/4382183 to your computer and use it in GitHub Desktop.
Save mslinn/4382183 to your computer and use it in GitHub Desktop.
Paypal IPN script for Play 2.1
package controllers
import play.api._
import libs.ws.WS
import play.api.mvc._
import com.micronautics.paypal.{TransactionProcessor, PaypalTransactions}
import java.net.URLEncoder
import concurrent.{Await, ExecutionContext}
import concurrent.duration.Duration
import java.util.concurrent.TimeUnit
import concurrent.Future
import play.api.libs.ws.{ Response => WSResponse }
import com.typesafe.config.ConfigFactory
object PayPalController extends Controller {
var live = true
lazy val defaultConfigStr = """
| paypal {
| receiverEmail = "not@set.com"
| }""".stripMargin
lazy val defaultConfig = try {
ConfigFactory.parseString(defaultConfigStr)
} catch {
case e: Throwable =>
println(e.getMessage)
sys.exit(-3)
}
lazy val config = ConfigFactory.load()
lazy val mergedConfig = config.withFallback(defaultConfig)
lazy val receiverEmail = findDef("paypal.receiverEmail")
/** Look up variable in environment then in merged config */
def findDef(name: String): String = {
val envValue: Option[String] = sys.env.get(name)
if (envValue == None) mergedConfig.getString(name) else envValue.get
}
def url =
if (live)
"https://www.paypal.com/cgi-bin/webscr?cmd=_notify-validate&"
else
"https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_notify-validate&"
def synchronousPost(url: String, dataMap: Map[String, Seq[String]], timeout: Duration=Duration.create(30, TimeUnit.SECONDS)): String = {
import ExecutionContext.Implicits.global
val params = dataMap.map { case (k, v) => "%s=%s".format(k, URLEncoder.encode(v.head, "UTF-8")) }.mkString("&")
val future: Future[WSResponse] = WS.url(url).
withHeaders(("Content-Type", "application/x-www-form-urlencoded")).
post(params)
try {
Await.result(future, timeout)
future.value.get.get.body
} catch {
case ex: Exception =>
Logger.error(ex.toString)
ex.toString
}
}
/** @see [[https://www.paypal.com/cgi-bin/webscr?cmd=p/acc/ipn-info-outside]] */
def ipn = Action { implicit request =>
request.body.asFormUrlEncoded match {
case Some(dataMap) =>
Logger.info("\nPaypal request: " + dataMap)
synchronousPost(url, dataMap + ("cmd" -> List("_notify-validate"))) match {
case "VERIFIED" =>
val paymentStatus = dataMap.getOrElse("payment_status", List("")).head
if (paymentStatus == "Completed") {
val txn = PaypalTransactions.applySeq(dataMap)
PaypalTransactions.findByTxnId(txn.txnId) match {
case Some(txn) =>
Logger.info("Ignoring duplicate transaction: " + txn.toStringAll)
case None =>
// Validate that the "receiver_email" is an email address registered in our PayPal account
if (txn.receiverEmail!=receiverEmail)
Logger.warn("Potential fraud attempt: receiver_email did not match in " + txn.toStringAll)
else
new TransactionProcessor(txn)(request).processTransaction
}
} else { // paymentStatus might be "Pending" or "Failed"
Logger.warn("Verified but payment_status is " + paymentStatus)
}
case response => // most likely response is "INVALID"
Logger.warn("Could not verify Paypal transaction via POST to %s; verification response: '%s'".format(url, response))
}
case None =>
}
Ok
}
}
package com.micronautics.paypal
import play.Logger
import java.util.{ List, Map }
import collection.JavaConversions._
import collection.immutable.HashMap
import slick.driver.MySQLDriver.simple._
/** The customerAddress table does not actually exist; this is my attempt to somehow put together nested tuples */
object CustomerAddresses
extends Table[(Int, String, String, String, String, String, String, String, String)]("customerAddress") {
def id = column[Int]("id", O NotNull)
def addressCity = column[String]("address_city")
def addressCountry = column[String]("address_country")
def addressCountryCode = column[String]("address_country_code")
def addressName = column[String]("address_name")
def addressState = column[String]("address_state")
def addressStatus = column[String]("address_status")
def addressStreet = column[String]("address_street")
def addressZip = column[String]("address_zip")
def * = id ~ addressCity ~ addressCountry ~ addressCountryCode ~ addressName ~ addressState ~ addressStatus ~ addressStreet ~ addressZip
}
case class CustomerAddress(
/** City of customer’s address - 40 chars */
addressCity: String,
/** Country of customer’s address - 64 chars */
addressCountry: String,
/** ISO 3166 country code associated with customer’s address - 2 chars */
addressCountryCode: String,
/** Name used with address (included when the customer provides a Gift Address) - 128 chars */
addressName: String,
/** State of customer’s address - 40 chars */
addressState: String,
/** Whether the customer provided a confirmed address ("confirmed" or "unconfirmed") - 20 chars */
addressStatus: String,
/** Customer’s street address - 200 chars */
addressStreet: String,
/** Zip code of customer’s address - 20 chars */
addressZip: String)
/** The paymentDetail table does not actually exist; this is my attempt to somehow put together nested tuples */
object PaymentDetails extends Table[(Int, String, String, String, String, String, String)]("paymentDetail") {
def id = column[Int]("id", O NotNull) // this field does not exist separately
def paymentDate = column[String]("payment_date")
def paymentStatus = column[String]("payment_status")
def paymentType = column[String]("payment_type")
def pendingReason = column[String]("pending_reason")
def reasonCode = column[String]("reason_code")
def tax = column[String]("tax")
def * = id ~ paymentDate ~ paymentStatus ~ paymentType ~ pendingReason ~ reasonCode ~ tax
}
case class PaymentDetail(
/** Time/date stamp generated by PayPal, in the following format: HH:MM:SS DD Mmm YY, YYYY PST - 28 chars */
paymentDate: String,
/** The status of the payment:
Canceled_Reversal: A reversal has been canceled. For example, you won a dispute with the customer, and the funds for the transaction that was
reversed have been returned to you.
Completed: The payment has been completed, and the funds have been added successfully to your account balance.
Created: A German ELV payment is made using Express Checkout.
Denied: You denied the payment. This happens only if the payment was previously pending because of possible reasons described for the
pending_reason variable or the Fraud_Management_Filters_x variable.
Expired: This authorization has expired and cannot be captured.
Failed: The payment has failed. This happens only if the payment was made from your customer’s bank account.
Pending: The payment is pending. See pending_reason for more information.
Refunded: You refunded the payment.
Reversed: A payment was reversed due to a chargeback or other type of reversal. The funds have been removed from your account balance and
returned to the buyer. The reason for the reversal is specified in the ReasonCode element.
Processed: A payment has been accepted.
Voided: This authorization has been voided. */
paymentStatus: String,
/** echeck: This payment was funded with an eCheck.
instant: This payment was funded with PayPal balance, credit card, or Instant Transfer. */
paymentType: String,
/** This variable is set only if payment_status = Pending.
address: The payment is pending because your customer did not include a confirmed shipping address and your Payment Receiving Preferences is
set yo allow you to manually accept or deny each of these payments. To change your preference, go to the Preferences section of your Profile.
authorization: You set the payment action to Authorization and have not yet captured funds.
echeck: The payment is pending because it was made by an eCheck that has not yet cleared.
intl: The payment is pending because you hold a non-U.S. account and do not have a withdrawal mechanism. You must manually accept or deny
this payment from your Account Overview.
multi-currency: You do not have a balance in the currency sent, and you do not have your Payment Receiving Preferences set to
automatically convert and accept this payment. You must manually accept or deny this payment.
order: You set the payment action to Order and have not yet captured funds.
paymentreview: The payment is pending while it is being reviewed by PayPal for risk.
unilateral: The payment is pending because it was made to an email address that is not yet registered or confirmed.
upgrade: The payment is pending because it was made via credit card and you must upgrade your account to Business or Premier status in order
to receive the funds. upgrade can also mean that you have reached the monthly limit for transactions on your account.
verify: The payment is pending because you are not yet verified. You must verify your account before you can accept this payment.
other: The payment is pending for a reason other than those listed above.
For more information, contact PayPal Customer Service. */
pendingReason: String,
/** This variable is set if payment_status =Reversed, Refunded, or Cancelled_Reversal.
adjustment_reversal: Reversal of an adjustment buyer-complaint: A reversal has occurred on this transaction due to a
complaint about the transaction from your customer.
chargeback: A reversal has occurred on this transaction due to a chargeback by your customer.
chargeback_reimbursement: Reimbursement for a chargeback chargeback_settlement: Settlement of a chargeback
guarantee: A reversal has occurred on this transaction due to your customer triggering a money-back guarantee.
other: Non-specified reason.
refund: A reversal has occurred on this transaction because you have given the customer a refund.
NOTE: Additional codes may be returned. */
reasonCode: String,
/** Amount of tax charged on payment. PayPal appends the number of the item (e.g., item_name1, item_name2). The tax variable is included
only if there was a specific tax amount applied to a particular shopping cart item. Because total tax may apply to other items in the cart, the sum
of tax might not total to tax. */
tax: String)
/** This is the only object that actually exists in persisted form */
object PaypalTransactions extends Table[(Int, String, String, String, String, String, String, String, String, String,
String, String, String, String, String, String
/*, CustomerAddress, PaymentDetail*/)]("paypalTransaction") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def charset = column[String]("charset")
def contactPhone = column[String]("contact_phone")
def custom = column[String]("custom")
def firstName = column[String]("first_name")
def lastName = column[String]("last_name")
def mcCurrency = column[String]("mc_currency")
def mcFee = column[String]("mc_fee")
def mcGross = column[String]("mc_gross")
def memo = column[String]("memo")
def payerBusinessName = column[String]("payer_business_name")
def payerEmail = column[String]("payer_email")
def payerId = column[String]("payer_id")
def payerStatus = column[String]("payer_status")
def txnId = column[String]("txn_id")
def verifySign = column[String]("verifySign")
// Should there be a def for CustomerAddress & PaymentDetail?
// When <> is not commented out, gives error: Overloaded method value [<>] cannot be applied to
// (com.micronautics.paypal.CustomerAddress.type, com.micronautics.paypal.CustomerAddress =>
// Option[(String, String, String, String, String, String, String, String)])
def * = id ~ charset ~ contactPhone ~ custom ~ firstName ~ lastName ~ mcCurrency ~ mcFee ~ mcGross ~ memo ~
payerBusinessName ~ payerEmail ~ payerId ~ payerStatus ~ txnId ~ verifySign
//<> (CustomerAddress, CustomerAddress.unapply _) <> (PaymentDetail, PaymentDetail.unapply _)
// what is this for?
//val q = for { f <- PaypalTransactions } yield f
//p.firstOption.map{case(name, age, address) => PaypalTransaction(name, age, address)}
}
object PaypalTransaction {
def applyList(dataMap: Map[String, List[String]]) = {
val newMap = new HashMap[String, String]()
dataMap.keySet foreach { key => newMap.put(key, dataMap.get(key).get(0)) }
apply(newMap)
}
def apply(dataMap: Map[String, String]) = {
def getItem(fieldName: String): String = getItem2(fieldName, true)
def getItem2(fieldName: String, required: Boolean): String = {
dataMap.get(fieldName) match {
case value if value!=null =>
value
case null =>
if (required)
throw new Exception("Required parameter " + fieldName + " not present or has no value")
""
}
}
Logger.debug("Form data: " + dataMap)
val address = CustomerAddress(
addressCity = getItem("address_city"),
addressCountry = getItem("address_country"),
addressCountryCode = getItem("address_country_code"),
addressName = getItem("address_name"),
addressState = getItem("address_state"),
addressStatus = getItem("address_status"),
addressStreet = getItem("address_street"),
addressZip = getItem("address_zip"))
val payment = PaymentDetail(
paymentDate = getItem("payment_date"),
paymentStatus = getItem("payment_status"),
paymentType = getItem("payment_type"),
pendingReason = getItem("pending_reason"),
reasonCode = getItem("reason_code"),
tax = getItem("tax")
)
// fields have been factored into other case classes so there are fewer than 23 remaining fields
new PaypalTransaction(
customerAddress = address,
charset = getItem("charset"),
contactPhone = getItem("contact_phone"),
custom = getItem2("custom", false),
firstName = getItem("first_name"),
lastName = getItem("last_name"),
mcCurrency = getItem("mc_currency"),
mcFee = getItem2("mc_fee", false),
mcGross = getItem("mc_gross"),
memo = getItem2("memo", false),
payerBusinessName = getItem2("payer_business_name", false),
payerEmail = getItem("payer_email"),
payerId = getItem("payer_id"),
payerStatus = getItem2("payer_status", false),
paymentDetail = payment,
txnId = getItem("txn_id"),
verifySign = getItem("verify_sign"))
}
}
case class PaypalTransaction(
customerAddress: CustomerAddress,
/** 20 chars */
charset: String,
/** Customer’s telephone number - 20 chars */
contactPhone: String,
/** Defined by store configuration - 128 chars is probably safe */
custom: String,
/** Customer’s first name - 64 chars */
firstName: String,
/** Customer's last name - 64 chars */
lastName: String,
/** */
mcCurrency: String,
/** Transaction fee associated with the payment. mc_gross minus mc_fee equals the amount deposited into the
* receiver_email account. Equivalent to payment_fee for USD payments. If this amount is negative, it signifies a
* refund or reversal, and either of those payment statuses can be for the full or partial amount of the original
* transaction fee. */
mcFee: String,
/** Full amount of the customer's payment, before transaction fee is subtracted. Equivalent to payment_gross for USD
* payments. If this amount is negative, it signifies a refund or reversal, and either of those payment statuses can
* be for the full or partial amount of the original transaction. */
mcGross: String,
/** Memo as entered by the customer in the PayPal Website Payments note field - 255 chars */
memo: String,
/** Customer’s company name, if customer is a business - 127 chars */
payerBusinessName: String,
/** Customer’s primary email address. Use this email to provide any credits - 127 chars */
payerEmail: String, // TODO add this to schema
/** Unique customer ID - 13 chars */
payerId: String,
/** Look for 'verified' - 20 chars */
payerStatus: String, // TODO add this to schema
paymentDetail: PaymentDetail,
/** Transaction ID */
txnId: String,
/** Not sure */
verifySign: String) {
def processTransaction() {
// error is: Overloaded method value [insert] cannot be applied to (com.micronautics.paypal.PaypalTransaction)
//PaypalTransactions.insert(this) // how is this supposed to work? Use the * operator somehow?
// do more things...
}
}
@alpeb
Copy link

alpeb commented Dec 26, 2012

Line 27 was because sometimes we get &var1=foo1&var2=&var3=foo3 , e.g. one var not having value (var2) which broke the map built there. BUT you can remove lines 27 to 34 and instead move up your dataMap!

About the else on line 40, there are a ton of IPN callbacks that won't have payment_status=Completed. For example notifications about subscription payments. If you're not doing subscriptions, I guess one could just log those calls just in case.

@glidester
Copy link

Line 92 of the PaypalController.scala needs to be:
Ok("")

If you use just "Ok" then the IPN simulator returns you the error message "IPN not detecting Content-type" which is very misleading. Might just be a simulator issue but if you're trying to validate your code using the simulator its quite worrying! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment