From 5aecd686e85a621bd1f6ec0c3146b13e8d7d8a67 Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 25 Mar 2022 07:09:45 +0000 Subject: [PATCH] Implement Xendit TPI service --- README.md | 2 +- modules/gateways/xendit/autoload.php | 14 + .../xendit/form/update-card-remote-iframe.php | 168 +++++++++++ .../gateways/xendit/handler/update-card.php | 67 +++++ modules/gateways/xendit/hooks.php | 29 ++ modules/gateways/xendit/lib/ActionBase.php | 197 +++++++++++++ modules/gateways/xendit/lib/Link.php | 183 ++++++++++++ .../xendit/lib/Model/XenditTransaction.php | 31 +++ modules/gateways/xendit/lib/Recurring.php | 133 +++++++++ modules/gateways/xendit/lib/XenditRequest.php | 260 ++++++++++++++++++ .../gateways/xendit/tests/WHMCSModuleTest.php | 43 +++ modules/gateways/xendit/tests/_bootstrap.php | 10 + 12 files changed, 1136 insertions(+), 1 deletion(-) create mode 100644 modules/gateways/xendit/autoload.php create mode 100644 modules/gateways/xendit/form/update-card-remote-iframe.php create mode 100644 modules/gateways/xendit/handler/update-card.php create mode 100644 modules/gateways/xendit/hooks.php create mode 100644 modules/gateways/xendit/lib/ActionBase.php create mode 100644 modules/gateways/xendit/lib/Link.php create mode 100644 modules/gateways/xendit/lib/Model/XenditTransaction.php create mode 100644 modules/gateways/xendit/lib/Recurring.php create mode 100644 modules/gateways/xendit/lib/XenditRequest.php create mode 100644 modules/gateways/xendit/tests/WHMCSModuleTest.php create mode 100644 modules/gateways/xendit/tests/_bootstrap.php diff --git a/README.md b/README.md index f29a931..f88b099 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ https://docs.whmcs.com/System_Requirements ## Configuration ## 1. Access your WHMCS admin page. 2. Go to menu Setup -> Payments -> Payment Gateways. -3. There are will be `**Xendit Payment Gateway Module**` +3. There are will be `Xendit Payment Gateway Module` 4. Then choose Setup -> Payments -> Payment Gateways -> Manage Existing Gateways 5. Put the `secretKey` and `publicKey` (Open Xendit Dashboard > Settings > API Keys > Generate Secret Key > Copy SecretKey & PublicKey) 6. Click Save Changes diff --git a/modules/gateways/xendit/autoload.php b/modules/gateways/xendit/autoload.php new file mode 100644 index 0000000..23b2768 --- /dev/null +++ b/modules/gateways/xendit/autoload.php @@ -0,0 +1,14 @@ + + + + + + + + + <?= $title ?> + + + + + + + + + + +
+ + + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+ +
+ + + + + + + diff --git a/modules/gateways/xendit/handler/update-card.php b/modules/gateways/xendit/handler/update-card.php new file mode 100644 index 0000000..185d1ae --- /dev/null +++ b/modules/gateways/xendit/handler/update-card.php @@ -0,0 +1,67 @@ + true, + 'action' => $action, + 'invoice_id' => $invoiceId, + 'customer_id' => $customerId, + 'amount' => $amount, + 'currency' => $currencyCode, + 'transaction_id' => rand(100000, 999999), + 'card_token' => 'abc' . rand(100000, 999999), + 'card_type' => $cardType, + 'card_last_four' => substr($cardNumber, -4, 4), + 'card_expiry_date' => $cardExpiryMonth . $cardExpiryYear, + 'custom_reference' => $customReference, + ]); + +header('Location: ' . $redirectUri); +exit; diff --git a/modules/gateways/xendit/hooks.php b/modules/gateways/xendit/hooks.php new file mode 100644 index 0000000..7dd434a --- /dev/null +++ b/modules/gateways/xendit/hooks.php @@ -0,0 +1,29 @@ +storeTransactions($vars['invoiceid']); + + if($xenditRecurring->isRecurring($vars['invoiceid'])){ + $previousInvoice = $xenditRecurring->getPreviousInvoice($vars['invoiceid']); + if(!empty($previousInvoice) && !empty($previousInvoice->paymethodid)) + { + $invoice = $xenditRecurring->getInvoice($vars['invoiceid']); + $invoice->setAttribute("paymethodid", $previousInvoice->paymethodid); + $invoice->save(); + + // Capture invoice payment + $xenditRecurring->capture($invoice->id); + } + } +} +add_hook('InvoiceCreated', 1, 'hookInvoiceCreated'); diff --git a/modules/gateways/xendit/lib/ActionBase.php b/modules/gateways/xendit/lib/ActionBase.php new file mode 100644 index 0000000..26fc7c2 --- /dev/null +++ b/modules/gateways/xendit/lib/ActionBase.php @@ -0,0 +1,197 @@ +xenditRequest = new XenditRequest(); + } + + /** + * @return string + */ + public function getDomainName() + { + return $this->moduleDomain; + } + + /** + * @return void + */ + public function createTable() + { + // Create database. + if (!Capsule::schema()->hasTable('xendit_transactions')) { + Capsule::schema()->create('xendit_transactions', function ($table) { + $table->increments('id'); + $table->integer('invoiceid')->unsigned(); + $table->integer('orderid')->unsigned(); + $table->integer('relid')->unsigned(); + $table->string('type', 100); + $table->string('external_id', 255); + $table->string('transactionid', 255); + $table->string('status', 100); + $table->timestamps(); + }); + } + } + + /** + * @return mixed + */ + public function getXenditConfig() + { + return getGatewayVariables($this->moduleDomain); + } + + /** + * @return array + */ + public function createConfig() + { + return array( + 'FriendlyName' => array( + 'Type' => 'System', + 'Value' => 'Xendit Payment Gateway', + ), + 'description' => array( + 'FriendlyName' => '', + 'Type' => 'hidden', + 'Size' => '72', + 'Default' => '', + 'Description' => '
Xendit is a leading payment gateway for Indonesia, the Philippines and Southeast Asia
', + ), + 'publicKey' => array( + 'FriendlyName' => 'Public Key', + 'Type' => 'password', + 'Size' => '25', + 'Default' => '', + 'Description' => 'Enter secret key here', + ), + 'secretKey' => array( + 'FriendlyName' => 'Secret Key', + 'Type' => 'password', + 'Size' => '25', + 'Default' => '', + 'Description' => 'Enter secret key here', + ), + ); + } + + /** + * @param int $invoiceId + * @param bool $retry + * @return string + */ + protected function generateExternalId(int $invoiceId, bool $retry = false): string + { + return !$retry ? sprintf("WHMCS-%s", $invoiceId) : sprintf("WHMCS-%s-%s", $invoiceId, microtime(true)); + } + + /** + * @param int $invoiceId + * @return mixed + */ + public function getInvoice(int $invoiceId) + { + return Invoice::find($invoiceId); + } + + /** + * @return XenditTransaction + */ + public function storeTransaction(array $params = []) + { + return XenditTransaction::create($params); + } + + /** + * @param int $invoiceId + * @return mixed + */ + public function getTransactionFromInvoiceId(int $invoiceId) + { + return XenditTransaction::where("invoiceid", $invoiceId) + ->whereNotIn("status", ["EXPIRED"]) + ->get(); + } + + /** + * @param $transactions + * @param string $transactionid + * @param string $status + * @return bool + * @throws \Exception + */ + public function updateTransactions($transactions, string $transactionid = "", string $status = "PAID") + { + try{ + foreach ($transactions as $transaction){ + $transaction->setAttribute("status", $status); + if(!empty($transactionid)){ + $transaction->setAttribute("transactionid", $transactionid); + } + $transaction->save(); + } + return true; + }catch (\Exception $exception){ + throw new \Exception($exception->getMessage()); + } + } + + /** + * @param int $invoiceId + * @param array $xenditInvoiceData + * @param bool $success + * @return void + */ + public function confirmInvoice(int $invoiceId, array $xenditInvoiceData, bool $success) + { + $transactionId = $xenditInvoiceData['id']; + $paymentAmount = $xenditInvoiceData['paid_amount']; + $paymentFee = $xenditInvoiceData['fees_paid_amount']; + $transactionStatus = $success ? 'Success' : 'Failure'; + + $invoiceId = checkCbInvoiceID($invoiceId, $this->getDomainName()); + checkCbTransID($transactionId); + + if(isset($xenditInvoiceData['credit_card_charge_id']) && isset($xenditInvoiceData['credit_card_token'])){ + $cardInfo = $this->xenditRequest->getCardInfo($xenditInvoiceData['credit_card_charge_id']); + $cardExpired = $this->xenditRequest->getCardTokenInfo($xenditInvoiceData['credit_card_token']); + if(!empty($cardInfo) && !empty($cardExpired)){ + $lastDigit = substr($cardInfo["masked_card_number"], -4); + invoiceSaveRemoteCard( + $invoiceId, + $lastDigit, + $cardInfo["card_brand"], + sprintf("%s/%s", $cardExpired["card_expiration_month"], $cardExpired["card_expiration_year"]), + $xenditInvoiceData['credit_card_token'] + ); + } + } + + addInvoicePayment( + $invoiceId, + $transactionId, + $paymentAmount, + $paymentFee, + $this->getDomainName() + ); + + logTransaction($this->getDomainName(), $_POST, $transactionStatus); + } +} diff --git a/modules/gateways/xendit/lib/Link.php b/modules/gateways/xendit/lib/Link.php new file mode 100644 index 0000000..e58169d --- /dev/null +++ b/modules/gateways/xendit/lib/Link.php @@ -0,0 +1,183 @@ +items()->get() as $item){ + $items[] = [ + 'quantity' => 1, + 'name' => $item->description, + 'price' => (float) $item->amount, + ]; + } + return $items; + } + + /** + * @param array $params + * @return array + */ + protected function extractCustomer(array $params) + { + return [ + 'given_names' => $params['clientdetails']['firstname'] . ' ' . $params['clientdetails']['lastname'], + 'mobile_number' => $params['clientdetails']['phonenumber'] + ]; + } + + /** + * @param array $params + * @param bool $retry + * @return array + */ + protected function generateInvoicePayload(array $params, bool $retry = false): array + { + $invoice = $this->getInvoice($params["invoiceid"]); + + return [ + 'external_id' => $this->generateExternalId($params["invoiceid"], $retry), + 'payer_email' => $params['clientdetails']['email'], + 'description' => $params["description"], + 'items' => $this->extractItems($invoice), + 'fees' => array(['type' => 'Payment Fee', 'value' => (float)$params['paymentfee']]), + 'amount' => $params['amount'] + (float)$params['paymentfee'], + 'invoice_duration' => $params['expired'], + 'success_redirect_url' => $this->invoiceUrl($params['invoiceid'], $params['systemurl'], false), + 'failure_redirect_url' => $this->invoiceUrl($params['invoiceid'], $params['systemurl'], false), + 'should_charge_multiple_use_token' => true, + 'customer' => $this->extractCustomer($params) + ]; + } + + /** + * @param $invoiceId + * @param string $systemurl + * @param bool $success + * @return string + */ + protected function invoiceUrl($invoiceId, string $systemurl, bool $success = true): string + { + return $success + ? $systemurl. "/modules/gateways/callback/". $this->getDomainName() .".php?status=success&id=". $invoiceId + : $systemurl . '/viewinvoice.php?id=' . $invoiceId; + } + + /** + * @param array $params + * @param string $invoiceUrl + * @return string + */ + protected function generateFormParam(array $params, string $invoiceUrl = "") + { + $postfields = array(); + $postfields['invoice_id'] = $params['invoiceid']; + $postfields['description'] = $params["description"]; + $postfields['amount'] = $params['amount']; + $postfields['currency'] = $params['currency']; + $postfields['first_name'] = $params['clientdetails']['firstname']; + $postfields['last_name'] = $params['clientdetails']['lastname']; + $postfields['email'] = $params['clientdetails']['email']; + $postfields['address1'] = $params['clientdetails']['address1']; + $postfields['address2'] = $params['clientdetails']['address2']; + $postfields['city'] = $params['clientdetails']['city']; + $postfields['state'] = $params['clientdetails']['state']; + $postfields['postcode'] = $params['clientdetails']['postcode']; + $postfields['country'] = $params['clientdetails']['country']; + $postfields['phone'] = $params['clientdetails']['phonenumber']; + $postfields['callback_url'] = $params['systemurl'] . '/modules/gateways/callback/' . $this->getDomainName() . '.php'; + $postfields['return_url'] = $params['returnurl']; + + $htmlOutput = '
'; + foreach ($postfields as $k => $v) { + $htmlOutput .= ''; + } + + $htmlOutput .= sprintf( + '', + $params['systemurl'] . '/modules/gateways/' . $this->getDomainName() . '/logo.png' + ); + + $htmlOutput .= '
'; + $htmlOutput .= '
'; + + return $htmlOutput; + } + + /** + * @param array $params + * @param bool $force + * @return string + * @throws \Exception + */ + public function generatePaymentLink(array $params, bool $force = false): string + { + try { + // Get transaction + $transactions = $this->getTransactionFromInvoiceId($params["invoiceid"]); + + // If force create new invoice + if($force){ + $createInvoice = $this->xenditRequest->createInvoice( + $this->generateInvoicePayload($params, true) + ); + $url = $createInvoice['invoice_url']; + + $this->updateTransactions( + $transactions, + $createInvoice["id"], + "PENDING" + ); + return $this->generateFormParam($params, $url); + } + + // Get Xendit Invoice by transactionid (Xendit invoice_id) + $xenditInvoice = false; + if(!empty($transactions) && !empty($transactions[0]->transactionid)){ + $xenditInvoice = $this->xenditRequest->getInvoiceById($transactions[0]->transactionid); + } + + // Check xendit invoice status + if(!empty($xenditInvoice)){ + if($xenditInvoice['status'] == "PAID"){ + $this->updateTransactions($transactions); + $this->confirmInvoice( + $params["invoiceid"], + $xenditInvoice, + true + ); + + // Redirect to success page + header('Location:' . sprintf("%scart.php?a=complete", $params['systemurl'])); + exit; + }elseif($xenditInvoice['status'] == "EXPIRED"){ + $this->updateTransactions($transactions, "", "EXPIRED"); + return $this->generatePaymentLink($params, true); + }else{ + $url = $xenditInvoice['invoice_url']; + } + }else{ + $createInvoice = $this->xenditRequest->createInvoice( + $this->generateInvoicePayload($params) + ); + $url = $createInvoice['invoice_url']; + $this->updateTransactions( + $transactions, + $createInvoice["id"], + "PENDING" + ); + } + + return $this->generateFormParam($params, $url); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + } +} diff --git a/modules/gateways/xendit/lib/Model/XenditTransaction.php b/modules/gateways/xendit/lib/Model/XenditTransaction.php new file mode 100644 index 0000000..48d7ae4 --- /dev/null +++ b/modules/gateways/xendit/lib/Model/XenditTransaction.php @@ -0,0 +1,31 @@ +where("invoiceid", "!=", $invoiceId) + ->whereIn("relid", $relationIds) + ->whereIn("type", $types) + ->orderBy('created_at', 'desc') + ->first(); + + if(!empty($xenditTransaction)){ + return $xenditTransaction; + } + return false; + } + + /** + * @param int $invoiceId + * @return false|mixed + */ + public function getPreviousInvoice(int $invoiceId) + { + $invoice = $this->getInvoice($invoiceId); + if(empty($invoice)) + throw \Exception("Invoice does not exists!"); + + $orderIds = []; + $items = []; + foreach ($invoice->items()->get() as $item){ + $items[$item->relid] = $item->type; + + foreach (self::WHMCS_PRODUCTS as $product){ + foreach ($item->$product()->get() as $service){ + $orderIds[] = $service->orderid; + } + } + } + + // If invoice does not have order OR invoice created for multi Order then IGNORE + if(empty($orderIds) || count($orderIds) > 1){ + return false; + } + + // Get previous transaction + $xenditTransaction = $this->getPreviousTransaction( + $orderIds[0], + $invoiceId, + array_keys($items), + array_values($items) + ); + if(empty($xenditTransaction)){ + return false; + } + + return $this->getInvoice($xenditTransaction->invoiceid); + } + + /** + * @param int $invoiceId + * @return bool + */ + public function isRecurring(int $invoiceId): bool + { + $recurringData = $this->getRecurringBillingInfo($invoiceId); + if(!isset($recurringData["firstpaymentamount"]) && !isset($recurringData['firstcycleperiod'])){ + return true; + } + return false; + } + + /** + * @return void + */ + public function storeTransactions(int $invoiceid) + { + $invoice = $this->getInvoice($invoiceid); + if(empty($invoice)) + throw \Exception("Invoice does not exists!"); + + foreach ($invoice->items()->get() as $item){ + foreach (self::WHMCS_PRODUCTS as $product){ + foreach ($item->$product()->get() as $p){ + $this->storeTransaction( + [ + "invoiceid" => $invoiceid, + "orderid" => $p->orderid, + "relid" => $p->id, + "type" => $item->type, + "external_id" => $this->generateExternalId($invoiceid) + ] + ); + } + } + } + } + + /** + * @param int $invoiceId + * @return mixed + */ + public function capture(int $invoiceId) + { + return localAPI("CapturePayment", ["invoiceid" => $invoiceId]); + } +} diff --git a/modules/gateways/xendit/lib/XenditRequest.php b/modules/gateways/xendit/lib/XenditRequest.php new file mode 100644 index 0000000..b1b6c56 --- /dev/null +++ b/modules/gateways/xendit/lib/XenditRequest.php @@ -0,0 +1,260 @@ + $invoiceId]); + } + + + /** + * @param string $method + * @param string $endpoint + * @param array $param + * @return bool|string + */ + protected function request(string $method = 'GET', string $endpoint, array $param = []) + { + $curl = curl_init(); + + curl_setopt_array($curl, array( + CURLOPT_URL => sprintf("%s/%s", $this->tpi_server_domain, $endpoint), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 0, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_POSTFIELDS => $param["body"] ?? "", + CURLOPT_HTTPHEADER => $param["headers"] + )); + + $response = curl_exec($curl); + + curl_close($curl); + return $response; + } + + /** + * @param bool $usePublicKey + * @param string $version + * @return string[] + */ + protected function defaultHeader(bool $usePublicKey = false, string $version = ''): array + { + $gatewayParams = $this->getModuleConfig(); + $default_header = array( + 'content-type: application/json', + 'x-plugin-name: WHMCS', + 'x-plugin-version: 1.0.1' + ); + if ($usePublicKey) { // prioritize use of public key than oauth data for CC requests + $default_header[] = 'Authorization: Basic '.base64_encode($gatewayParams["publicKey"].':'); + } + else { + $default_header[] = "authorization-type: ApiKey"; + $default_header[] = 'Authorization: Basic '.base64_encode($gatewayParams["secretKey"].':'); + } + if (!empty($version)) { + $default_header[] = 'x-api-version: ' . $version; + } + if ($this->for_user_id) { + $default_header[] = 'for-user-id: ' . $this->for_user_id; + } + return $default_header; + } + + /** + * @param string $invoice_id + * @return mixed + */ + public function getInvoiceById(string $invoice_id) + { + try{ + $response = $this->request( + "GET", + '/payment/xendit/invoice/' . $invoice_id, [ + 'headers' => $this->defaultHeader() + ]); + return json_decode($response, true); + }catch (\Exception $e){ + throw new Exception($e->getMessage()); + } + } + + /** + * @param array $param + * @return mixed + */ + public function createInvoice(array $param = []) + { + try{ + $response = $this->request("POST", '/payment/xendit/invoice', [ + 'headers' => $this->defaultHeader(), + 'body' => json_encode($param) + ]); + return json_decode($response, true); + }catch (\Exception $e){ + throw new Exception($e->getMessage()); + } + } + + /** + * @param array $param + * @return false|mixed + */ + public function createHost3DS(array $param = []) + { + try { + $response = $this->request("POST", '/payment/xendit/credit-card/hosted-3ds', [ + 'headers' => $this->defaultHeader(true, '2020-02-14'), + 'body' => json_encode($param) + ]); + return json_decode($response, true); + } catch (Exception $e) { + throw new Exception($e->getMessage()); + } + } + + /** + * @param array $params + * @return array + * @throws \Exception + */ + public function generateCCPaymentRequest(array $params = []) + { + $invoice = $this->getInvoice($params["invoiceid"]); + if(empty($invoice)) + throw new \Exception("Invoice does not exist"); + + return [ + "amount" => $params["amount"], + "currency" => $params["currency"], + "token_id" => $params["gatewayid"], + "external_id" => sprintf("WHMCS - %s", $params["invoiceid"] .'-'. uniqid()), + "store_name" => "WHMCS Testing", + "items" => $this->extractItems($invoice), + "customer" => $this->extractCustomerDetail($params), + "is_recurring" => true, + "should_charge_multiple_use_token" => true + ]; + } + + /** + * @param $payload + * @return mixed + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function createCharge($payload) + { + $default_header = $this->defaultHeader(); + try { + $response = $this->request("POST", 'payment/xendit/credit-card/charges', [ + 'headers' => $default_header, + 'body' => json_encode($payload) + ]); + return json_decode($response, true); + + } catch (Exception $e) { + throw new Exception($e->getMessage()); + } + } + + /** + * @param string $card_charge_id + * @return mixed + * @throws Exception + */ + public function getCardInfo(string $card_charge_id) + { + $default_header = $this->defaultHeader(); + try { + $response = $this->request("GET", 'payment/xendit/credit-card/charges/' . $card_charge_id, [ + 'headers' => $default_header + ]); + return json_decode($response, true); + + } catch (Exception $e) { + throw new Exception($e->getMessage()); + } + } + + /** + * @param string $card_token + * @return mixed + * @throws Exception + */ + public function getCardTokenInfo(string $card_token) + { + $default_header = $this->defaultHeader(); + try { + $response = $this->request("GET", 'payment/xendit/credit-card/token/' . $card_token, [ + 'headers' => $default_header + ]); + return json_decode($response, true); + + } catch (Exception $e) { + throw new Exception($e->getMessage()); + } + } + + /** + * @param array $param + * @return array[] + */ + public function extractCustomerDetail(array $param = []) + { + $customerDetails = [ + 'first_name' => $param['clientdetails']['firstname'], + 'last_name' => $param['clientdetails']['lastname'], + 'email' => $param['clientdetails']['email'], + 'phone_number' => $param['clientdetails']['phonenumber'], + 'address_city' => $param['clientdetails']['city'], + 'address_postal_code' => $param['clientdetails']['postcode'], + 'address_line_1' => $param['clientdetails']['address1'], + 'address_line_2' => $param['clientdetails']['address2'], + 'address_state' => $param['clientdetails']['state'], + 'address_country' => $param['clientdetails']['country'], + ]; + return [ + "billing_details" => $customerDetails, + "shipping_details" => $customerDetails + ]; + } + + /** + * @param $invoice + * @return array + */ + public function extractItems($invoice): array + { + $items = array(); + foreach ($invoice['items']['item'] as $item) { + $item_price = (float) $item['amount']; + $items[] = [ + 'quantity' => 1, + 'name' => $item['description'], + 'price' => $item_price, + ]; + } + return $items; + } +} diff --git a/modules/gateways/xendit/tests/WHMCSModuleTest.php b/modules/gateways/xendit/tests/WHMCSModuleTest.php new file mode 100644 index 0000000..5fed871 --- /dev/null +++ b/modules/gateways/xendit/tests/WHMCSModuleTest.php @@ -0,0 +1,43 @@ +assertTrue(function_exists($this->moduleName . '_config')); + } + + /** + * Asserts the required config option array keys are present. + */ + public function testRequiredConfigOptionsParametersAreDefined() + { + $result = call_user_func($this->moduleName . '_config'); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('description', $result); + $this->assertArrayHasKey('author', $result); + $this->assertArrayHasKey('language', $result); + $this->assertArrayHasKey('version', $result); + $this->assertArrayHasKey('fields', $result); + } +} diff --git a/modules/gateways/xendit/tests/_bootstrap.php b/modules/gateways/xendit/tests/_bootstrap.php new file mode 100644 index 0000000..7b61460 --- /dev/null +++ b/modules/gateways/xendit/tests/_bootstrap.php @@ -0,0 +1,10 @@ +