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"}]}