diff --git a/.gitignore b/.gitignore index 8a282a5..0d77fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ composer.lock composer.phar phpunit.xml +.idea diff --git a/README.md b/README.md index 095201d..443b453 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,63 @@ if ($response->isSuccessful()) { } ``` +### Void or Reverse a 'captured' transaction +###### Only available for limited merchants and channels + +The following is the example to void a captured transaction, your can refer to MOLPay Reversal Request api spec. + +```php +$gateway = Omnipay::create('MOLPay'); + +$gateway->setMerchantId('your_merchant_id'); +$gateway->setVerifyKey('your_verify_key'); +$gateway->setSecretKey('your_secret_key'); + +$request = $gateway->void([ + 'transactionReference' => '25248208' +]); + +$response = $request->send(); + +if ($response->isSuccessful()) { + // Update your data model +} else { + echo $response->getMessage(); +} +``` + +### Request Partial Refund for a 'captured' or 'settled' transaction +###### Only available for limited merchants and channels + +To perform a partial refund, you need to specify more parameters as below + +```php +$gateway = Omnipay::create('MOLPay'); + +$gateway->setMerchantId('your_merchant_id'); +$gateway->setVerifyKey('your_verify_key'); +$gateway->setSecretKey('your_secret_key'); + +$request = $gateway->refund([ + 'transactionReference' => '25248208', + 'refId' => 'merchant_refund_red_id', + 'amount' => '10.00', + 'channel' => $transaction_channel, // data saved from $gateway->purchase() response, e.g FPX_MB2U + 'bankCode' => $bank_code, // from user who request to refund + 'beneficiaryName' => $beneficiary_name, // from user who request to refund + 'beneficiaryAccountNo' => $beneficiary_account_no, // from user who request to refund +]); + +$response = $request->send(); + +// The refund process will take about 7-14 days after the request sent +if ($response->isSuccessful() || $response->isPending() ) { + // Update your data model +} else { + echo $response->getMessage(); +} +``` + ## Out Of Scope Omnipay does not cover recurring payments or billing agreements, and so those features are not included in this package. Extensions to this gateway are always welcome. diff --git a/src/Gateway.php b/src/Gateway.php index 9d829ca..ad70cb3 100644 --- a/src/Gateway.php +++ b/src/Gateway.php @@ -198,8 +198,31 @@ public function completePurchase(array $parameters = array()) 'sKey' => $this->httpRequest->request->get('skey'), 'status' => $this->httpRequest->request->get('status'), 'transactionReference' => $this->httpRequest->request->get('tranID'), + 'channel' => $this->httpRequest->request->get('channel') ) ) ); } + + /** + * Create a refund request + * + * @param array $parameters + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function refund(array $parameters = array()) + { + return $this->createRequest('\Omnipay\MOLPay\Message\PartialRefundRequest', $parameters); + } + + /** + * Create a void request + * + * @param array $parameters + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function void(array $parameters = array()) + { + return $this->createRequest('\Omnipay\MOLPay\Message\ReversalRequest', $parameters); + } } diff --git a/src/Message/CompletePurchaseRequest.php b/src/Message/CompletePurchaseRequest.php index b8a14e3..9100508 100644 --- a/src/Message/CompletePurchaseRequest.php +++ b/src/Message/CompletePurchaseRequest.php @@ -179,6 +179,16 @@ public function setTransactionReference($value) return $this->setParameter('transactionReference', $value); } + public function getChannel() + { + return $this->getParameter('channel'); + } + + public function setChannel($value) + { + return $this->setParameter('channel', $value); + } + /** * {@inheritdoc} */ diff --git a/src/Message/PartialRefundRequest.php b/src/Message/PartialRefundRequest.php new file mode 100644 index 0000000..d8f09ff --- /dev/null +++ b/src/Message/PartialRefundRequest.php @@ -0,0 +1,223 @@ +setParameter('refId', $value); + } + + /** + * @return mixed + */ + public function getRefId() + { + return $this->getParameter('refId'); + } + + /** + * @param $value + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function setChannel($value) + { + return $this->setParameter('channel', $value); + } + + /** + * @return mixed + */ + public function getChannel() + { + return $this->getParameter('channel'); + } + + /** + * @param $value + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function setBankCode($value) + { + return $this->setParameter('bankCode', $value); + } + + /** + * @return mixed + */ + public function getBankCode() + { + return $this->getParameter('bankCode'); + } + + /** + * @param $value + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function setBeneficiaryName($value) + { + return $this->setParameter('beneficiaryName', $value); + } + + /** + * @return mixed + */ + public function getBeneficiaryName() + { + return $this->getParameter('beneficiaryName'); + } + + /** + * @param $value + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function setBeneficiaryAccountNo($value) + { + return $this->setParameter('beneficiaryAccountNo', $value); + } + + /** + * @return mixed + */ + public function getBeneficiaryAccountNo() + { + return $this->getParameter('beneficiaryAccountNo'); + } + + /** + * @param $value + * @return \Omnipay\Common\Message\AbstractRequest + */ + public function setMdrFlag($value) + { + return $this->setParameter('mdrFlag', $value); + } + + /** + * @return mixed + */ + public function getMdrFlag() + { + return $this->getParameter('mdrFlag'); + } + + /** + * {@inheritdoc} + */ + public function getData() + { + $this->validate('merchantId', 'refId', 'transactionReference', 'amount'); + + $data = array(); + $data['RefundType'] = $this->getRefundType(); + $data['MerchantID'] = $this->getMerchantId(); + $data['RefID'] = $this->getRefId(); + $data['TxnID'] = $this->getTransactionReference(); + $data['Channel'] = $this->getChannel(); + $data['Amount'] = $this->getAmount(); + $data['BankCode'] = $this->getBankCode(); + $data['BeneficiaryName'] = $this->getBeneficiaryName(); + $data['BeneficiaryAccNo'] = $this->getBeneficiaryAccountNo(); + $data['Signature'] = $this->generateSignature(); + $data['mdr_flag'] = $this->getMdrFlag(); + $data['notify_url'] = $this->getNotifyUrl(); + + return $data; + } + + /** + * {@inheritdoc} + */ + public function getEndpoint() + { + return $this->getTestMode() ? $this->sandboxEndpoint : $this->endpoint; + } + + /** + * {@inheritdoc} + */ + public function getHttpMethod() + { + return 'POST'; + } + + /** + * {@inheritdoc} + */ + public function sendData($data) + { + $httpRequest = $this->httpClient->createRequest( + $this->getHttpMethod(), + $this->getEndpoint(), + null, + $data + ); + + $httpResponse = $httpRequest->send(); + + return $this->response = new PartialRefundResponse($this, $httpResponse->json()); + } + + /** + * Generate Signature + * @return string + */ + protected function generateSignature() + { + $this->validate('merchantId', 'refId', 'transactionReference', 'amount', 'secretKey'); + + return md5($this->getRefundType() . $this->getMerchantId() .$this->getRefId() . $this->getTransactionReference() . $this->getAmount() . $this->getSecretKey()); + } +} diff --git a/src/Message/PartialRefundResponse.php b/src/Message/PartialRefundResponse.php new file mode 100644 index 0000000..90a1e85 --- /dev/null +++ b/src/Message/PartialRefundResponse.php @@ -0,0 +1,74 @@ +data)) { + return $this->data['error_desc']; + } + // Handle MOLPay return success with status 'Rejected' + else if (array_key_exists('reason', $this->data)) { + return $this->data['reason']; + } + // Handle MOLPay returned unknown exceptions that not specified in spec + else { + return 'Unknown error'; + } + } + + /** + * {@inheritdoc} + */ + public function isSuccessful() + { + if (array_key_exists('error_code', $this->data)) { + return false; + } + + // API returned 'success', not actual '00' at this development time + return ($this->data['Status'] === '00' || strtolower($this->data['Status']) === 'success'); + } + + /** + * {@inheritdoc} + */ + public function isPending() + { + if (array_key_exists('error_code', $this->data)) { + return false; + } + + // API returned 'pending', not actual '22' at this development time + return ($this->data['Status'] === '22' || strtolower($this->data['Status']) === 'pending'); + } +} diff --git a/src/Message/ReversalRequest.php b/src/Message/ReversalRequest.php new file mode 100644 index 0000000..eb607e5 --- /dev/null +++ b/src/Message/ReversalRequest.php @@ -0,0 +1,93 @@ +validate('transactionReference', 'merchantId'); + + $data = array(); + $data['txnID'] = $this->getTransactionReference(); + $data['domain'] = $this->getMerchantId(); + $data['skey'] = $this->generateSKey(); + + return $data; + } + + /** + * {@inheritdoc} + */ + public function getEndpoint() + { + return $this->getTestMode() ? $this->sandboxEndpoint : $this->endpoint; + } + + /** + * {@inheritdoc} + */ + public function getHttpMethod() + { + return 'POST'; + } + + /** + * {@inheritdoc} + */ + public function sendData($data) + { + $httpRequest = $this->httpClient->createRequest( + $this->getHttpMethod(), + $this->getEndpoint(), + null, + $data + ); + + $httpResponse = $httpRequest->send(); + + return $this->response = new ReversalResponse($this, $httpResponse->getBody()); + } + + /** + * Generate SKey + * @return string + */ + protected function generateSKey() + { + $this->validate('transactionReference', 'merchantId', 'secretKey'); + + return md5($this->getTransactionReference() . $this->getMerchantId() . $this->getSecretKey()); + } +} diff --git a/src/Message/ReversalResponse.php b/src/Message/ReversalResponse.php new file mode 100644 index 0000000..ca7abdd --- /dev/null +++ b/src/Message/ReversalResponse.php @@ -0,0 +1,77 @@ + 'Success', + '11' => 'Failure', + '12' => 'Invalid or unmatched security hash string', + '13' => 'Not a refundable transaction', + '14' => 'Transaction date more than 45 days', + '15' => 'Requested day is on settlement day', + '16' => 'Forbidden transaction', + '17' => 'Transaction not found' + ); + + + /** + * ReversalResponse constructor. + * @param RequestInterface $request + * @param mixed $data + */ + public function __construct(RequestInterface $request, $data) + { + $this->request = $request; + + $search = array("\r\n", "\n", "\r"); + $data = str_replace($search, '&', $data); + + // Parse string to key value mapping + parse_str($data, $this->data); + } + + /** + * {@inheritdoc} + */ + public function getMessage() + { + return $this->statCodeMessages[$this->data['StatCode']]; + } + + /** + * {@inheritdoc} + */ + public function isSuccessful() + { + return '00' === $this->data['StatCode']; + } +} diff --git a/tests/GatewayTest.php b/tests/GatewayTest.php index bd94b9f..6f35d58 100644 --- a/tests/GatewayTest.php +++ b/tests/GatewayTest.php @@ -107,4 +107,43 @@ public function testCompletePurchaseError() $this->assertNull($response->getTransactionReference()); $this->assertEquals('Invalid date', $response->getMessage()); } + + public function testVoid() + { + $request = $this->gateway->void(array( + 'transactionReference' => '25248208' + )); + + $this->assertInstanceOf('\Omnipay\MOLPay\Message\ReversalRequest', $request); + $this->assertSame('25248208', $request->getTransactionReference()); + $endPoint = $request->getEndpoint(); + $this->assertSame('https://api.molpay.com/MOLPay/API/refundAPI/refundAPI/refund.php', $endPoint); + $data = $request->getData(); + $this->assertNotEmpty($data); + } + + public function testRefund() + { + $request = $this->gateway->refund(array( + 'transactionReference' => '25248208', + 'refId' => 'merchant_refund_ref_id', + 'amount' => '10.00', + 'bankCode' => 'MBBEMYKL', + 'beneficiaryName' => 'beneficiary_name', + 'beneficiaryAccountNo' => 'beneficiary_account_no', + )); + + $this->assertInstanceOf('\Omnipay\MOLPay\Message\PartialRefundRequest', $request); + $this->assertSame('25248208', $request->getTransactionReference()); + $this->assertSame('merchant_refund_ref_id', $request->getRefId()); + $this->assertSame('10.00', $request->getAmount()); + $this->assertSame('MBBEMYKL', $request->getBankCode()); + $this->assertSame('beneficiary_name', $request->getBeneficiaryName()); + $this->assertSame('beneficiary_account_no', $request->getBeneficiaryAccountNo()); + + $endPoint = $request->getEndpoint(); + $this->assertSame('https://api.molpay.com/MOLPay/API/refundAPI/index.php', $endPoint); + $data = $request->getData(); + $this->assertNotEmpty($data); + } } diff --git a/tests/Message/PartialRefundRequestTest.php b/tests/Message/PartialRefundRequestTest.php new file mode 100644 index 0000000..134d3e0 --- /dev/null +++ b/tests/Message/PartialRefundRequestTest.php @@ -0,0 +1,50 @@ +getHttpClient(); + $request = $this->getHttpRequest(); + + $this->request = new PartialRefundRequest($client, $request); + } + + public function testGetData() + { + $this->request->setMerchantId('your_merchant_id'); + $this->request->setRefId('merchant_refund_ref_id'); + $this->request->setTransactionReference('25248208'); + $this->request->setChannel('FPX_MB2U'); + $this->request->setAmount('10.00'); + $this->request->setBankCode('MBBEMYKL'); + $this->request->setBeneficiaryName('beneficiary_name'); + $this->request->setBeneficiaryAccountNo('beneficiary_account_no'); + $this->request->setSecretKey('your_secret_key'); + + $expected = array(); + $expected['RefundType'] = 'P'; + $expected['MerchantID'] = 'your_merchant_id'; + $expected['RefID'] = 'merchant_refund_ref_id'; + $expected['TxnID'] = '25248208'; + $expected['Channel'] = 'FPX_MB2U'; + $expected['Amount'] = '10.00'; + $expected['BankCode'] = 'MBBEMYKL'; + $expected['BeneficiaryName'] = 'beneficiary_name'; + $expected['BeneficiaryAccNo'] = 'beneficiary_account_no'; + $expected['Signature'] = 'aafbef8720b13a33b37370a1a3b1c238'; + $expected['mdr_flag'] = null; + $expected['notify_url'] = null; + + $this->assertEquals($expected, $this->request->getData()); + } +} diff --git a/tests/Message/PartialRefundResponseTest.php b/tests/Message/PartialRefundResponseTest.php new file mode 100644 index 0000000..e15b416 --- /dev/null +++ b/tests/Message/PartialRefundResponseTest.php @@ -0,0 +1,26 @@ +getMockHttpResponse('PartialRefundPending.txt'); + $response = new PartialRefundResponse($this->getMockRequest(), $httpResponse->json()); + + $this->assertTrue($response->isPending()); + } + + public function testPartialRefundError() + { + $httpResponse = $this->getMockHttpResponse('PartialRefundError.txt'); + $response = new PartialRefundResponse($this->getMockRequest(), $httpResponse->json()); + + $this->assertFalse($response->isSuccessful()); + $this->assertFalse($response->isPending()); + $this->assertSame('Exceed refund amount for this transaction.', $response->getMessage()); + } +} \ No newline at end of file diff --git a/tests/Message/ReversalRequestTest.php b/tests/Message/ReversalRequestTest.php new file mode 100644 index 0000000..3a78a3c --- /dev/null +++ b/tests/Message/ReversalRequestTest.php @@ -0,0 +1,35 @@ +getHttpClient(); + $request = $this->getHttpRequest(); + + $this->request = new ReversalRequest($client, $request); + } + + public function testGetData() + { + $this->request->setTransactionReference('25248208'); + $this->request->setMerchantId('your_merchant_id'); + $this->request->setSecretKey('your_secret_key'); + + $expected = array(); + $expected['txnID'] = '25248208'; + $expected['domain'] = 'your_merchant_id'; + $expected['skey'] = 'd07b97e2b8c7234792d3fb1fe56db619'; + + $this->assertEquals($expected, $this->request->getData()); + } +} diff --git a/tests/Message/ReversalResponseTest.php b/tests/Message/ReversalResponseTest.php new file mode 100644 index 0000000..159485e --- /dev/null +++ b/tests/Message/ReversalResponseTest.php @@ -0,0 +1,41 @@ +getMockRequest(), + "TxnID=25248203\nDomain=your_merchant_id\nStatDate=2018-01-28 15:53:19\nStatCode=00\nVrfKey=f56d5ea9932861454b7fd69851f57f7c"); + + $this->assertEquals(array( + 'TxnID' => '25248203', + 'Domain' => 'your_merchant_id', + 'StatDate' => '2018-01-28 15:53:19', + 'StatCode' => '00', + 'VrfKey' => 'f56d5ea9932861454b7fd69851f57f7c'), + $response->getData()); + } + + public function testReversalSuccess() + { + $httpResponse = $this->getMockHttpResponse('ReversalSuccess.txt'); + $response = new ReversalResponse($this->getMockRequest(), $httpResponse->getBody()); + + $this->assertTrue($response->isSuccessful()); + $this->assertSame('Success', $response->getMessage()); + } + + public function testRefundFailure() + { + $httpResponse = $this->getMockHttpResponse('ReversalFailure.txt'); + $response = new ReversalResponse($this->getMockRequest(), $httpResponse->getBody()); + + $this->assertFalse($response->isSuccessful()); + $this->assertSame('Forbidden transaction', $response->getMessage()); + } +} diff --git a/tests/Mock/PartialRefundError.txt b/tests/Mock/PartialRefundError.txt new file mode 100644 index 0000000..9a28c43 --- /dev/null +++ b/tests/Mock/PartialRefundError.txt @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +Date: Sun, 28 Jan 2018 11:21:38 GMT +Server: Apache +Content-Length: 93 +Connection: close +Content-Type: application/json + +{ + "error_code": "PR011", + "error_desc": "Exceed refund amount for this transaction." +} \ No newline at end of file diff --git a/tests/Mock/PartialRefundPending.txt b/tests/Mock/PartialRefundPending.txt new file mode 100644 index 0000000..6e17388 --- /dev/null +++ b/tests/Mock/PartialRefundPending.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +Date: Sun, 28 Jan 2018 11:21:38 GMT +Server: Apache +Content-Length: 119 +Connection: close +Content-Type: application/json + +{ + "RefundType": "P", + "MerchantID": "your_merchant_id", + "RefID": "merchant_refund_ref_id", + "RefundID": 19, + "TxnID": 25248208, + "Amount": "10.00", + "Status": "pending", + "Signature": "816071a1a72a260b87d7570beb0a670a" +} \ No newline at end of file diff --git a/tests/Mock/ReversalFailure.txt b/tests/Mock/ReversalFailure.txt new file mode 100644 index 0000000..167b56b --- /dev/null +++ b/tests/Mock/ReversalFailure.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +Date: Sun, 28 Jan 2018 11:21:38 GMT +Server: Apache +Content-Length: 119 +Connection: close +Content-Type: text/plain; charset=utf-8 + +TxnID=25248203 +Domain=your_merchant_id +StatDate=2018-01-28 15:53:19 +StatCode=16 +VrfKey=3917697f5dd0cda28ba7408eb5d07e62 \ No newline at end of file diff --git a/tests/Mock/ReversalSuccess.txt b/tests/Mock/ReversalSuccess.txt new file mode 100644 index 0000000..a799e7d --- /dev/null +++ b/tests/Mock/ReversalSuccess.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +Date: Sun, 28 Jan 2018 11:21:38 GMT +Server: Apache +Content-Length: 119 +Connection: close +Content-Type: text/plain; charset=utf-8 + +TxnID=25248203 +Domain=your_merchant_id +StatDate=2018-01-28 15:53:19 +StatCode=00 +VrfKey=f56d5ea9932861454b7fd69851f57f7c \ No newline at end of file