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 = '';
+
+ 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 @@
+