diff --git a/composer.json b/composer.json
index 1377e600..f702b881 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,7 @@
"require-dev": {
"omnipay/tests": "^3",
"squizlabs/php_codesniffer": "^3",
- "phpro/grumphp": "^0.14"
+ "phpro/grumphp": "1.3.0"
},
"extra": {
"branch-alias": {
diff --git a/grumphp.yml b/grumphp.yml
index 4b767a09..03db70d7 100644
--- a/grumphp.yml
+++ b/grumphp.yml
@@ -1,4 +1,4 @@
-parameters:
+grumphp:
git_dir: .
bin_dir: vendor/bin
tasks:
diff --git a/src/Message/RestAuthorizeRequest.php b/src/Message/RestAuthorizeRequest.php
index 19762f95..f193996d 100644
--- a/src/Message/RestAuthorizeRequest.php
+++ b/src/Message/RestAuthorizeRequest.php
@@ -5,6 +5,8 @@
namespace Omnipay\PayPal\Message;
+use Omnipay\Common\Exception\InvalidCreditCardException;
+
/**
* PayPal REST Authorize Request
*
@@ -251,7 +253,20 @@ public function getData()
$data['transactions'][0]['item_list']["items"] = $itemList;
}
- if ($this->getCardReference()) {
+ if ($this->getCardReference() && substr($this->getCardReference(), 0, 2) === 'B-') {
+ // Card references can be for a billing agreement (format B-....) or a stored card format CARD-...
+ // here billing agreement
+ // https://developer.paypal.com/docs/limited-release/reference-transactions/#use-a-reference-transaction-to-make-a-payment
+ $this->validate('amount');
+ $data['payer']['funding_instruments'][] = array(
+ 'billing' => array(
+ 'billing_agreement_id' => $this->getCardReference(),
+ ),
+ );
+ $data['payer']['payment_method'] = 'paypal';
+ } elseif ($this->getCardReference()) {
+ // stored card
+ //https://developer.paypal.com/docs/integration/direct/vault/#pay-with-vaulted-card
$this->validate('amount');
$data['payer']['funding_instruments'][] = array(
@@ -259,9 +274,8 @@ public function getData()
'credit_card_id' => $this->getCardReference(),
),
);
- } elseif ($this->getCard()) {
+ } elseif ($this->validCardPresent()) {
$this->validate('amount', 'card');
- $this->getCard()->validate();
$data['payer']['funding_instruments'][] = array(
'credit_card' => array(
@@ -304,6 +318,32 @@ public function getData()
return $data;
}
+ /**
+ * Has a valid card been passed in the Omnipay parameters.
+ *
+ * Omnipay supports details other than card details in the card parameter (e.g.
+ * billing address) so a generic omnipay integration might set the 'card' when
+ * there is no number present. In which case the Rest integration should fall
+ * back to the next method.
+ */
+ public function validCardPresent()
+ {
+ $card = $this->getCard();
+ if (!$card) {
+ return false;
+ }
+ try {
+ $card->validate();
+ } catch (InvalidCreditCardException $e) {
+ if (stristr($e->getMessage(), 'is required')) {
+ return false;
+ } else {
+ throw $e;
+ }
+ }
+ return true;
+ }
+
/**
* Get the experience profile id
*
diff --git a/src/Message/RestAuthorizeResponse.php b/src/Message/RestAuthorizeResponse.php
index 9359db0b..004be778 100644
--- a/src/Message/RestAuthorizeResponse.php
+++ b/src/Message/RestAuthorizeResponse.php
@@ -61,7 +61,11 @@ public function getTransactionReference()
// The transaction reference for a paypal purchase request or for a
// paypal create subscription request ends up in the execute URL
// in the links section of the response.
- $completeUrl = $this->getCompleteUrl();
+ // this has been HACKED. Paypal changed it's product offering & introduced
+ // smart buttons instead of checkout.js and it needs something different returned.
+ // notes here https://github.com/thephpleague/omnipay-paypal/issues/228 but I couldn't see a
+ // good answer here so I just hacked.
+ $completeUrl = $this->getRedirectUrl();
if (empty($completeUrl)) {
return parent::getTransactionReference();
}
@@ -70,6 +74,8 @@ public function getTransactionReference()
// The last element of the URL should be "execute"
$execute = end($urlParts);
+ // part of hack above.
+ return (str_replace('webscr?cmd=_express-checkout&token=', '', $execute));
if (!in_array($execute, array('execute', 'agreement-execute'))) {
return parent::getTransactionReference();
}
diff --git a/src/Message/RestCompleteCreateCardRequest.php b/src/Message/RestCompleteCreateCardRequest.php
new file mode 100644
index 00000000..1368b364
--- /dev/null
+++ b/src/Message/RestCompleteCreateCardRequest.php
@@ -0,0 +1,117 @@
+getRedirectUrl(). Once
+ * the customer has approved the agreement and be returned to the returnUrl
+ * in the call. The returnUrl can contain the following code to complete
+ * the agreement:
+ *
+ *
+ * // Create a gateway for the PayPal REST Gateway
+ * // (routes to GatewayFactory::create)
+ * $gateway = Omnipay::create('PayPal_Rest');
+ *
+ * // Initialise the gateway
+ * $gateway->initialize(array(
+ * 'clientId' => 'MyPayPalClientId',
+ * 'secret' => 'MyPayPalSecret',
+ * 'testMode' => true, // Or false when you are ready for live transactions
+ * ));
+ *
+ * // Crate a card
+ * $transaction = $gateway->completeCreateCard(array(
+ * 'transactionReference' => $subscription_id,
+ * ));
+ * $response = $transaction->send();
+ * if ($response->isSuccessful()) {
+ * echo "Complete Subscription transaction was successful!\n";
+ * $subscription_id = $response->getTransactionReference();
+ * echo "Subscription reference = " . $subscription_id;
+ * }
+ *
+ *
+ * Note that the subscription_id that you get from calling the response's
+ * getTransactionReference() method at the end of the completeSubscription
+ * call will be different to the one that you got after calling the response's
+ * getTransactionReference() method at the end of the createSubscription
+ * call. The one that you get from completeSubscription is the correct
+ * one to use going forwards (e.g. for cancelling or updating the subscription).
+ *
+ * ### Request Sample
+ *
+ * This is from the PayPal web site:
+ *
+ *
+ * curl -v POST https://api.sandbox.paypal.com/v1/payments/billing-agreements/EC-0JP008296V451950C/agreement-execute \
+ * -H 'Content-Type:application/json' \
+ * -H 'Authorization: Bearer ' \
+ * -d '{}'
+ *
+ *
+ * ### Response Sample
+ *
+ * This is from the PayPal web site:
+ *
+ *
+ * {
+ * "id": "I-0LN988D3JACS",
+ * "links": [
+ * {
+ * "href": "https://api.sandbox.paypal.com/v1/payments/billing-agreements/I-0LN988D3JACS",
+ * "rel": "self",
+ * "method": "GET"
+ * }
+ * ]
+ * }
+ *
+ *
+ * @link https://developer.paypal.com/docs/api/#execute-an-agreement
+ * @see RestCreateSubscriptionRequest
+ * @see Omnipay\PayPal\RestGateway
+ */
+class RestCompleteCreateCardRequest extends AbstractRestRequest
+{
+ public function getData()
+ {
+ $this->validate('transactionReference');
+
+ return ['token_id' => $this->getTransactionReference()];
+ }
+
+ /**
+ * Get transaction endpoint.
+ *
+ * Subscriptions are executed using the /billing-agreements resource.
+ *
+ * @return string
+ */
+ protected function getEndpoint()
+ {
+ return parent::getEndpoint() . '/billing-agreements/agreements';
+ }
+}
diff --git a/src/Message/RestCreateCardRequest.php b/src/Message/RestCreateCardRequest.php
index f36fa6d3..0870bc29 100644
--- a/src/Message/RestCreateCardRequest.php
+++ b/src/Message/RestCreateCardRequest.php
@@ -72,39 +72,73 @@ class RestCreateCardRequest extends AbstractRestRequest
{
public function getData()
{
- $this->validate('card');
- $this->getCard()->validate();
+ if ($this->isCardPresent()) {
+ $this->validate('card');
+ $this->getCard()->validate();
- $data = array(
- 'number' => $this->getCard()->getNumber(),
- 'type' => $this->getCard()->getBrand(),
- 'expire_month' => $this->getCard()->getExpiryMonth(),
- 'expire_year' => $this->getCard()->getExpiryYear(),
- 'cvv2' => $this->getCard()->getCvv(),
- 'first_name' => $this->getCard()->getFirstName(),
- 'last_name' => $this->getCard()->getLastName(),
- 'billing_address' => array(
- 'line1' => $this->getCard()->getAddress1(),
- //'line2' => $this->getCard()->getAddress2(),
- 'city' => $this->getCard()->getCity(),
- 'state' => $this->getCard()->getState(),
- 'postal_code' => $this->getCard()->getPostcode(),
- 'country_code' => strtoupper($this->getCard()->getCountry()),
- )
- );
+ $data = array(
+ 'number' => $this->getCard()->getNumber(),
+ 'type' => $this->getCard()->getBrand(),
+ 'expire_month' => $this->getCard()->getExpiryMonth(),
+ 'expire_year' => $this->getCard()->getExpiryYear(),
+ 'cvv2' => $this->getCard()->getCvv(),
+ 'first_name' => $this->getCard()->getFirstName(),
+ 'last_name' => $this->getCard()->getLastName(),
+ 'billing_address' => array(
+ 'line1' => $this->getCard()->getAddress1(),
+ //'line2' => $this->getCard()->getAddress2(),
+ 'city' => $this->getCard()->getCity(),
+ 'state' => $this->getCard()->getState(),
+ 'postal_code' => $this->getCard()->getPostcode(),
+ 'country_code' => strtoupper($this->getCard()->getCountry()),
+ )
+ );
- // There's currently a quirk with the REST API that requires line2 to be
- // non-empty if it's present. Jul 14, 2014
- $line2 = $this->getCard()->getAddress2();
- if (!empty($line2)) {
- $data['billing_address']['line2'] = $line2;
+ // There's currently a quirk with the REST API that requires line2 to be
+ // non-empty if it's present. Jul 14, 2014
+ $line2 = $this->getCard()->getAddress2();
+ if (!empty($line2)) {
+ $data['billing_address']['line2'] = $line2;
+ }
+ } else {
+ // We are creating a token to rebill a paypal account.
+ // this equates to the meaning of 'createCard' in other processors
+ // such as Stripe.
+ // https://developer.paypal.com/docs/limited-release/reference-transactions/#create-billing-agreement
+ $data = ["description" => $this->getDescription(),
+ "payer" => ["payment_method" => "PAYPAL"],
+ "plan" =>
+ [
+ "type" => "MERCHANT_INITIATED_BILLING",
+ "merchant_preferences" =>
+ [
+ "return_url" => $this->getReturnUrl(),
+ "cancel_url" => $this->getCancelUrl(),
+ "notify_url" => $this->getNotifyUrl(),
+ "accepted_pymt_type" => "INSTANT",
+ "skip_shipping_address" => true,
+ "immutable_shipping_address" => false,
+ ]
+ ]
+ ];
}
-
return $data;
}
protected function getEndpoint()
{
- return parent::getEndpoint() . '/vault/credit-cards';
+ if ($this->isCardPresent()) {
+ return parent::getEndpoint() . '/vault/credit-cards';
+ } else {
+ return parent::getEndpoint() . '/billing-agreements/agreement-tokens';
+ }
+ }
+
+ /**
+ * Is the card present (or are we dealing with a paypal account transaction)
+ */
+ protected function isCardPresent()
+ {
+ return $this->getCard()->getNumber();
}
}
diff --git a/src/Message/RestResponse.php b/src/Message/RestResponse.php
index 4ffdbe9b..2806ef33 100644
--- a/src/Message/RestResponse.php
+++ b/src/Message/RestResponse.php
@@ -42,6 +42,39 @@ public function getTransactionReference()
return $this->data['id'];
}
+ if (isset($this->data['token_id'])) {
+ // This would be present when dealing with a case where the user
+ // authorize's their card within paypal. In this case the transactionReference
+ // will be used during the redirect.
+ return $this->data['token_id'];
+ }
+
+ return null;
+ }
+
+ /*
+ * The fee taken by PayPal for the transaction. Available from the Sale
+ * object in RelatedResources only once a transaction has been completed
+ * and funds have been received by merchant.
+ *
+ * https://developer.paypal.com/docs/api/payments/v1/#definition-sale
+ *
+ *
+ * There may be a 'fee' field associated with amount details object but
+ * this seems to pertain to a handling_fee charged to client rather than
+ * the PayPal processing fee.
+ *
+ * https://developer.paypal.com/docs/api/payments/v1/#definition-details
+ */
+ public function getProcessorFeeAmount()
+ {
+ if (!empty($this->data['transactions']) &&
+ !empty($this->data['transactions'][0]['related_resources']) &&
+ !empty($this->data['transactions'][0]['related_resources'][0]['sale']) &&
+ !empty($this->data['transactions'][0]['related_resources'][0]['sale']['transaction_fee'])) {
+ return $this->data['transactions'][0]['related_resources'][0]['sale']['transaction_fee']['value'];
+ }
+
return null;
}
@@ -63,10 +96,31 @@ public function getCode()
return $this->statusCode;
}
+ /**
+ * Get a string that will represent the stored card in future requests.
+ *
+ * If they have authorised the payment through submitting a card through Omnipay
+ * this will be a reference to the card in the vault. If they have authorised the payment
+ * through paypal this will be a reference to an approved billing agreement.
+ */
public function getCardReference()
{
+ if ($this->isPaypalApproval()) {
+ if (isset($this->data['funding_instruments'])) {
+ return $this->data['payer']['funding_instruments'][0]['billing']['billing_agreement_id'];
+ }
+ return false;
+ }
if (isset($this->data['id'])) {
return $this->data['id'];
}
}
+
+ public function isPaypalApproval()
+ {
+ if (!isset($this->data['payer']['payment_method'])) {
+ return false;
+ }
+ return ($this->data['payer']['payment_method'] === 'paypal');
+ }
}
diff --git a/src/RestGateway.php b/src/RestGateway.php
index d6ae4f3f..62e63625 100644
--- a/src/RestGateway.php
+++ b/src/RestGateway.php
@@ -560,6 +560,20 @@ public function createCard(array $parameters = array())
return $this->createRequest('\Omnipay\PayPal\Message\RestCreateCardRequest', $parameters);
}
+ /**
+ * Complete a card creation when using paypal account method.
+ *
+ * Use this call to create a billing agreement after the buyer approves it.
+ *
+ * @link https://developer.paypal.com/docs/limited-release/reference-transactions/#create-billing-agreement-token
+ * @param array $parameters
+ * @return \Omnipay\PayPal\Message\RestCompleteCreateCardRequest
+ */
+ public function completeCreateCard(array $parameters = array())
+ {
+ return $this->createRequest('\Omnipay\PayPal\Message\RestCompleteCreateCardRequest', $parameters);
+ }
+
/**
* Delete a credit card from the vault.
*
diff --git a/tests/Message/RestAuthorizeRequestTest.php b/tests/Message/RestAuthorizeRequestTest.php
index 5f0f962b..ec04f853 100644
--- a/tests/Message/RestAuthorizeRequestTest.php
+++ b/tests/Message/RestAuthorizeRequestTest.php
@@ -47,6 +47,33 @@ public function testGetDataWithoutCard()
$this->assertSame('https://www.example.com/cancel', $data['redirect_urls']['cancel_url']);
}
+ /**
+ * This tests that having a card object with no card details acts as 'no card'.
+ *
+ * We may have a card object holding billing details but no card details. This
+ * should be treated as a card-not-present rather than as invalid.
+ */
+ public function testGetDataWitLimitedCard()
+ {
+ $this->request->setTransactionId('abc123');
+ $this->request->setDescription('Sheep');
+ $this->request->setCard(new CreditCard(['firstName' => 'Example']));
+
+ $data = $this->request->getData();
+
+ $this->assertSame('authorize', $data['intent']);
+ $this->assertSame('paypal', $data['payer']['payment_method']);
+ $this->assertSame('10.00', $data['transactions'][0]['amount']['total']);
+ $this->assertSame('USD', $data['transactions'][0]['amount']['currency']);
+ $this->assertSame('abc123 : Sheep', $data['transactions'][0]['description']);
+
+ // Funding instruments must not be set, otherwise paypal API will give error 500.
+ $this->assertArrayNotHasKey('funding_instruments', $data['payer']);
+
+ $this->assertSame('https://www.example.com/return', $data['redirect_urls']['return_url']);
+ $this->assertSame('https://www.example.com/cancel', $data['redirect_urls']['cancel_url']);
+ }
+
public function testGetDataWithCard()
{
$card = new CreditCard($this->getValidCard());
diff --git a/tests/Message/RestResponseTest.php b/tests/Message/RestResponseTest.php
index ce852bb8..f583d9a8 100644
--- a/tests/Message/RestResponseTest.php
+++ b/tests/Message/RestResponseTest.php
@@ -14,6 +14,7 @@ public function testPurchaseSuccess()
$this->assertTrue($response->isSuccessful());
$this->assertSame('44E89981F8714392Y', $response->getTransactionReference());
+ $this->assertSame("0.50", $response->getProcessorFeeAmount());
$this->assertNull($response->getMessage());
}
@@ -25,6 +26,7 @@ public function testPurchaseFailure()
$this->assertFalse($response->isSuccessful());
$this->assertNull($response->getTransactionReference());
+ $this->assertNull($response->getProcessorFeeAmount());
$this->assertSame('Invalid request - see details', $response->getMessage());
}
@@ -37,6 +39,7 @@ public function testCompletePurchaseSuccess()
$this->assertTrue($response->isSuccessful());
$this->assertSame('9EA05739TH369572R', $response->getTransactionReference());
+ $this->assertNull($response->getProcessorFeeAmount());
$this->assertNull($response->getMessage());
}
@@ -72,6 +75,7 @@ public function testAuthorizeSuccess()
$this->assertTrue($response->isSuccessful());
$this->assertSame('58N7596879166930B', $response->getTransactionReference());
+ $this->assertNull($response->getProcessorFeeAmount());
$this->assertNull($response->getMessage());
}
diff --git a/tests/Mock/RestPurchaseSuccess.txt b/tests/Mock/RestPurchaseSuccess.txt
index 0e43e2f7..d12703c8 100644
--- a/tests/Mock/RestPurchaseSuccess.txt
+++ b/tests/Mock/RestPurchaseSuccess.txt
@@ -9,4 +9,4 @@ Date: Thu, 03 Jul 2014 14:11:10 GMT
Content-Type: application/json
Content-Length: 1243
-{"id":"PAY-6RT04683U7444573DKO2WI6A","create_time":"2014-07-03T14:11:04Z","update_time":"2014-07-03T14:11:10Z","state":"approved","intent":"sale","payer":{"payment_method":"credit_card","funding_instruments":[{"credit_card":{"type":"mastercard","number":"xxxxxxxxxxxx5559","expire_month":"12","expire_year":"2018","first_name":"Betsy","last_name":"Buyer"}}]},"transactions":[{"amount":{"total":"7.47","currency":"USD","details":{"subtotal":"7.47"}},"description":"This is the payment transaction description.","related_resources":[{"sale":{"id":"44E89981F8714392Y","create_time":"2014-07-03T14:11:04Z","update_time":"2014-07-03T14:11:10Z","state":"completed","amount":{"total":"7.47","currency":"USD"},"parent_payment":"PAY-6RT04683U7444573DKO2WI6A","links":[{"href":"https://api.sandbox.paypal.com/v1/payments/sale/44E89981F8714392Y","rel":"self","method":"GET"},{"href":"https://api.sandbox.paypal.com/v1/payments/sale/44E89981F8714392Y/refund","rel":"refund","method":"POST"},{"href":"https://api.sandbox.paypal.com/v1/payments/payment/PAY-6RT04683U7444573DKO2WI6A","rel":"parent_payment","method":"GET"}]}}]}],"links":[{"href":"https://api.sandbox.paypal.com/v1/payments/payment/PAY-6RT04683U7444573DKO2WI6A","rel":"self","method":"GET"}]}
+{"id":"PAY-6RT04683U7444573DKO2WI6A","create_time":"2014-07-03T14:11:04Z","update_time":"2014-07-03T14:11:10Z","state":"approved","intent":"sale","payer":{"payment_method":"credit_card","funding_instruments":[{"credit_card":{"type":"mastercard","number":"xxxxxxxxxxxx5559","expire_month":"12","expire_year":"2018","first_name":"Betsy","last_name":"Buyer"}}]},"transactions":[{"amount":{"total":"7.47","currency":"USD","details":{"subtotal":"7.47"}},"description":"This is the payment transaction description.","related_resources":[{"sale":{"id":"44E89981F8714392Y","create_time":"2014-07-03T14:11:04Z","update_time":"2014-07-03T14:11:10Z","state":"completed","amount":{"total":"7.47","currency":"USD"},"transaction_fee":{"value":"0.50","currency":"USD"},"parent_payment":"PAY-6RT04683U7444573DKO2WI6A","links":[{"href":"https://api.sandbox.paypal.com/v1/payments/sale/44E89981F8714392Y","rel":"self","method":"GET"},{"href":"https://api.sandbox.paypal.com/v1/payments/sale/44E89981F8714392Y/refund","rel":"refund","method":"POST"},{"href":"https://api.sandbox.paypal.com/v1/payments/payment/PAY-6RT04683U7444573DKO2WI6A","rel":"parent_payment","method":"GET"}]}}]}],"links":[{"href":"https://api.sandbox.paypal.com/v1/payments/payment/PAY-6RT04683U7444573DKO2WI6A","rel":"self","method":"GET"}]}