diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1311a3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +*/.DS_Store +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 1ca4a67..f21af9f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,84 @@ -# Xendit-FOSSBilling -Xendit payment module for FOSSBilling +
+ Xendit for FOSSBilling +

Xendit Integration for FOSSBilling

+ + + + GitHub +
+ +## Overview +Provide your [FOSSBilling](https://fossbilling.org) customers with a variety of payment options, including Credit/Debit cards, Bank Transfer, E-Wallets, and more through [Xendit](https://www.xendit.co). + +> **Warning** +> This extension, like FOSSBilling itself, is under active development and is currently in beta. There may be stability or security issues, and it is not yet recommended for use in active production environments! + +## Table of Contents +- [Overview](#overview) +- [Table of Contents](#table-of-contents) +- [Installation](#installation) + - [1). Extension directory](#1-extension-directory) + - [2). Manual installation](#2-manual-installation) +- [Configuration](#configuration) +- [Webhook Configuration](#webhook-configuration) +- [Usage](#usage) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) + +## Installation + +### 1). Extension directory +> Not yet implemented +> +> +### 2). Manual installation +1. Download the latest release from the [GitHub repository](https://github.com/FZFR/Xendit-FOSSBilling/releases) +2. Create a new folder named **Xendit** in the **/library/Payment/Adapter** directory of your FOSSBilling installation +3. Extract the archive you've downloaded in the first step into the new directory +4. Go to the "**Payment gateways**" page in your admin panel (under the "System" menu in the navigation bar) and find Xendit in the "**New payment gateway**" tab +5. Click the *cog icon* next to Xendit to install and configure Xendit + +## Configuration +1. Access Xendit Settings: In your FOSSBilling admin panel, find "**Xendit**" under "**Payment gateways.**" +2. Enter API Credentials: Input your Xendit `API Key` and `Webhook Verification Token`. You can obtain these from your Xendit dashboard. +3. Configure Preferences: Customize settings like sandbox mode and logging as needed. +4. Save Changes: Remember to update your configuration. +5. Test Transactions (Optional): Test your gateway integration through a payment process. +6. Go Live: Switch to live mode to start accepting real payments. + +## Webhook Configuration + +To set up webhooks: + +1. Log in to your Xendit dashboard. +2. Navigate to Settings > Webhooks. +3. Add a new webhook with the following URL: + `https://your-fossbilling-domain.com/ipn.php?gateway_id=payment_gateway_id` + (Replace `your-fossbilling-domain.com` with your actual domain) +4. Ensure the Webhook Verification Token in your Xendit settings matches the one in your FOSSBilling configuration. + +## Usage +Once you've installed and configured the module, you can start using Xendit as a payment gateway in your FOSSBilling setup. Customers will now see Xendit as an option during the payment process based on the configuration you have set. + +## Troubleshooting + +- Check the logs at `library/Payment/Adapter/Xendit/logs/xendit.log` for detailed information on transactions and errors. +- Ensure your server's IP is whitelisted in Xendit's settings if you're experiencing connection issues. +- Verify that the API keys and Webhook Verification Tokens are correctly entered in the FOSSBilling configuration. + + +## Contributing +We welcome contributions to enhance and improve this integration module. If you'd like to contribute, please follow these steps: + +1. Fork the repository. +2. Create a new branch for your feature or bugfix: `git checkout -b feature-name`. +3. Make your changes and commit them with a clear and concise commit message. +4. Push your branch to your fork: `git push origin feature-name` and create a [pull request](https://github.com/FZFR/Xendit-FOSSBilling/pulls). + +## License +This FOSSBilling Xendit Payment Gateway Integration module is open-source software licensed under the [Apache License 2.0](LICENSE). + +> *Note*: This module is not officially affiliated with [FOSSBilling](https://fossbilling.org) or [Xendit](https://www.xendit.co). Please refer to their respective documentation for detailed information on FOSSBilling and Xendit. + +For support or questions, feel free to contact us at me@fazza.fr \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c8d7676 --- /dev/null +++ b/composer.json @@ -0,0 +1,15 @@ +{ + "name": "fzfr/xendit-fossbilling", + "type": "library", + "authors": [ + { + "name": "FZFR", + "email": "me@fazza.fr" + } + ], + "require": { + "php": ">=8.0", + "ext-json": "*", + "monolog/monolog": "*" + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..0a533ed --- /dev/null +++ b/composer.lock @@ -0,0 +1,173 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8b5bc2b22ce0adf3cef005273954feee", + "packages": [ + { + "name": "monolog/monolog", + "version": "3.7.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f4393b648b78a5408747de94fca38beb5f7e9ef8", + "reference": "f4393b648b78a5408747de94fca38beb5f7e9ef8", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "phpstan/phpstan": "^1.9", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-strict-rules": "^1.4", + "phpunit/phpunit": "^10.5.17", + "predis/predis": "^1.1 || ^2", + "ruflin/elastica": "^7", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.7.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2024-06-28T09:40:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.0", + "ext-json": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +} \ No newline at end of file diff --git a/src/Xendit.php b/src/Xendit.php new file mode 100644 index 0000000..3d5714e --- /dev/null +++ b/src/Xendit.php @@ -0,0 +1,454 @@ +config = $config; + + $apiKey = $this->getApiKey(); + $webhookToken = $this->getWebhookToken(); + + if (empty($apiKey)) { + throw new Payment_Exception('The ":pay_gateway" payment gateway is not fully configured. Please configure the :missing', [':pay_gateway' => 'Xendit', ':missing' => 'API Key']); + } + if (empty($webhookToken)) { + throw new Payment_Exception('The ":pay_gateway" payment gateway is not fully configured. Please configure the :missing', [':pay_gateway' => 'Xendit', ':missing' => 'Webhook Verification Token']); + } + + $this->initLogger(); + } + + private function initLogger(): void + { + $this->logger = new Logger('Xendit'); + $logDir = __DIR__ . '/logs'; + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + $this->logger->pushHandler(new RotatingFileHandler($logDir . '/xendit.log', 0, Logger::DEBUG)); + } + + public function setDi(Pimple\Container $di): void + { + $this->di = $di; + } + + public function getDi(): ?Pimple\Container + { + return $this->di; + } + + public static function getConfig(): array + { + return [ + 'supports_one_time_payments' => true, + 'description' => 'Configure Xendit API key and Webhook Verification Token to start accepting payments via Xendit.', + 'logo' => [ + 'logo' => 'Xendit.png', + 'height' => '50px', + 'width' => '50px', + ], + 'form' => [ + 'api_key' => [ + 'text', + [ + 'label' => 'Xendit API Key', + 'required' => true, + ], + ], + 'sandbox_api_key' => [ + 'text', + [ + 'label' => 'Xendit Sandbox API Key', + 'required' => false, + ], + ], + 'webhook_token' => [ + 'text', + [ + 'label' => 'Webhook Verification Token', + 'required' => true, + ], + ], + 'sandbox_webhook_token' => [ + 'text', + [ + 'label' => 'Sandbox Webhook Verification Token', + 'required' => false, + ], + ], + 'use_sandbox' => [ + 'radio', + [ + 'label' => 'Use Sandbox', + 'multiOptions' => [ + '1' => 'Yes', + '0' => 'No', + ], + 'required' => true, + ], + ], + 'enable_logging' => [ + 'radio', + [ + 'label' => 'Enable Logging', + 'multiOptions' => [ + '1' => 'Yes', + '0' => 'No', + ], + 'required' => true, + ], + ], + ], + ]; + } + private function getConfigValue($key) + { + $prefix = $this->config['use_sandbox'] ? 'sandbox_' : ''; + return $this->config[$prefix . $key] ?? null; + } + + public function getHtml($api_admin, $invoice_id, $subscription) + { + try { + $invoice = $this->di['db']->load('Invoice', $invoice_id); + $xenditInvoice = $this->createXenditInvoice($invoice); + + if ($this->config['enable_logging']) { + $this->logger->info('Xendit invoice created: ' . json_encode($xenditInvoice)); + } + + return $this->generatePaymentForm($xenditInvoice['invoice_url'], $invoice->id); + } catch (Exception $e) { + if ($this->config['enable_logging']) { + $this->logger->error('Error in getHtml: ' . $e->getMessage()); + } + throw new Payment_Exception('Error processing Xendit payment: ' . $e->getMessage()); + } + } + + private function generatePaymentForm($invoiceUrl, $invoiceId): string + { + $html = '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + $html .= ''; + return $html; + } + + public function processTransaction($api_admin, $id, $data, $gateway_id) + { + if ($this->config['enable_logging']) { + $this->logger->info('Received Xendit callback: ' . json_encode($data)); + $this->logger->info('GET data: ' . json_encode($_GET)); + $this->logger->info('POST data: ' . json_encode($_POST)); + $this->logger->info('Raw input: ' . file_get_contents('php://input')); + } + + // Check if this is a return from success_redirect_url + if (isset($data['get']['bb_invoice_id'])) { + return $this->handleSuccessRedirect($data['get']['bb_invoice_id'], $gateway_id); + } + + return $this->handleWebhook($id, $data); + } + + private function handleSuccessRedirect($invoice_id, $gateway_id) + { + $invoiceModel = $this->di['db']->load('Invoice', $invoice_id); + + if ($invoiceModel->status !== 'paid') { + $this->updateInvoiceStatus($invoiceModel, 'paid'); + $tx = $this->createOrUpdateTransaction($invoice_id, $gateway_id, $invoiceModel); + $this->processPayment($invoiceModel, $tx); + if ($this->config['enable_logging']) { + $this->logger->info('Xendit payment auto-approved for invoice #' . $invoice_id); + } + } else { + if ($this->config['enable_logging']) { + $this->logger->info('Invoice #' . $invoice_id . ' is already paid. Skipping processing.'); + } + } + + return true; + } + + private function handleWebhook($id, $data) + { + $headers = getallheaders(); + $callbackToken = $headers['X-CALLBACK-TOKEN'] ?? ''; + if (!$this->verifyWebhookToken($callbackToken)) { + $this->logger->error('Invalid Xendit webhook token'); + http_response_code(403); + return false; + } + + $rawInput = file_get_contents('php://input'); + $ipn = json_decode($rawInput, true); + + if ($this->config['enable_logging']) { + $this->logger->info('Xendit webhook raw input: ' . $rawInput); + $this->logger->info('Xendit webhook decoded: ' . json_encode($ipn)); + } + + if (!isset($ipn['external_id'])) { + $this->logger->error('Invalid Xendit callback: missing external_id'); + http_response_code(400); + return false; + } + + $invoice_id = $ipn['external_id']; + $tx = $this->di['db']->load('Transaction', $id); + + if (!$tx->invoice_id) { + $tx->invoice_id = $invoice_id; + } + + $invoiceModel = $this->di['db']->load('Invoice', $invoice_id); + + if ($this->config['enable_logging']) { + $this->logger->info('Xendit payment status: ' . $ipn['status']); + } + + if ($ipn['status'] == 'PAID') { + $result = $this->processSuccessfulPayment($tx, $invoiceModel, $ipn); + } else { + $result = $this->handleFailedPayment($tx, $invoice_id, $ipn['status']); + } + + http_response_code(200); + return $result; + } + + + + private function processSuccessfulPayment($tx, $invoiceModel, $ipn) + { + $this->updateTransaction($tx, $ipn); + $this->updateInvoiceStatus($invoiceModel, 'paid'); + $this->recordPayment($invoiceModel, $tx, $ipn); + $this->updateClientBalance($invoiceModel, $tx, $ipn); + $this->processInvoiceCredits($invoiceModel); + + if ($this->config['enable_logging']) { + $this->logger->info('Xendit payment processed successfully for invoice #' . $invoiceModel->id); + } + return true; + } + + private function handleFailedPayment($tx, $invoice_id, $status) + { + $tx->error = 'Xendit payment status: ' . $status; + $tx->status = 'received'; + $tx->updated_at = date('Y-m-d H:i:s'); + $this->di['db']->store($tx); + + if ($this->config['enable_logging']) { + $this->logger->info('Xendit payment not completed for invoice #' . $invoice_id . '. Status: ' . $status); + } + return false; + } + + private function updateInvoiceStatus($invoiceModel, $status): void + { + $invoiceModel->status = $status; + $invoiceModel->paid_at = date('Y-m-d H:i:s'); + $this->di['db']->store($invoiceModel); + } + + private function createOrUpdateTransaction($invoice_id, $gateway_id, $invoiceModel) + { + $tx = $this->di['db']->find_one('Transaction', 'invoice_id = ?', [$invoice_id]); + if (!$tx) { + $tx = $this->di['db']->dispense('Transaction'); + $tx->invoice_id = $invoice_id; + } + + $tx->txn_status = 'complete'; + $tx->status = 'complete'; + $tx->amount = $invoiceModel->total; + $tx->currency = $invoiceModel->currency; + $tx->type = 'payment'; + $tx->gateway_id = $gateway_id; + $tx->updated_at = date('Y-m-d H:i:s'); + $this->di['db']->store($tx); + + return $tx; + } + + private function processPayment($invoiceModel, $tx): void + { + $invoiceService = $this->di['mod_service']('Invoice'); + $paymentService = $this->di['mod_service']('Invoice', 'Payment'); + $paymentService->recordPayment($invoiceModel->id, $tx->amount, 'Xendit payment (auto-approved)', $tx); + + $clientService = $this->di['mod_service']('Client'); + $client = $this->di['db']->getExistingModelById('Client', $invoiceModel->client_id); + $clientService->addFunds($client, $tx->amount, 'Xendit payment (auto-approved)', [ + 'type' => 'Xendit', + 'rel_id' => $tx->id, + ]); + + $invoiceService->payInvoiceWithCredits($invoiceModel); + $invoiceService->doBatchPayWithCredits(['client_id' => $client->id]); + } + + private function updateTransaction($tx, $ipn): void + { + $tx->txn_status = 'complete'; + $tx->txn_id = $ipn['id']; + $tx->amount = $ipn['paid_amount'] ?? $ipn['amount']; + $tx->currency = $ipn['currency']; + $tx->status = 'complete'; + $tx->updated_at = date('Y-m-d H:i:s'); + $this->di['db']->store($tx); + } + + private function recordPayment($invoiceModel, $tx, $ipn): void + { + $paymentService = $this->di['mod_service']('Invoice', 'Payment'); + $paymentService->recordPayment($invoiceModel->id, $tx->amount, 'Xendit payment: ' . $ipn['id'], $tx); + } + + private function updateClientBalance($invoiceModel, $tx, $ipn): void + { + $clientService = $this->di['mod_service']('Client'); + $client = $this->di['db']->getExistingModelById('Client', $invoiceModel->client_id); + $clientService->addFunds($client, $tx->amount, 'Xendit payment ' . $ipn['id'], [ + 'type' => 'Xendit', + 'rel_id' => $ipn['id'], + ]); + } + + private function processInvoiceCredits($invoiceModel): void + { + $invoiceService = $this->di['mod_service']('Invoice'); + $invoiceService->payInvoiceWithCredits($invoiceModel); + $invoiceService->doBatchPayWithCredits(['client_id' => $invoiceModel->client_id]); + } + + private function createXenditInvoice($invoice) + { + $invoiceService = $this->di['mod_service']('Invoice'); + + if (!$invoice instanceof \Model_Invoice) { + $invoice = $this->di['db']->load('Invoice', $invoice->id); + } + + $thankyou_url = $this->di['url']->link('/invoice/thank-you/' . $invoice->hash, [ + 'bb_invoice_id' => $invoice->id, + 'gateway_id' => 10, + 'restore_session' => session_id() + ]); + $invoice_url = $this->di['tools']->url('/invoice/' . $invoice->hash, ['restore_session' => session_id()]); + + $items = $this->di['db']->getAll("SELECT title FROM invoice_item WHERE invoice_id = :invoice_id", [':invoice_id' => $invoice->id]); + + $description = $this->createDetailedDescription($invoice, $items); + + $data = [ + 'external_id' => (string) $invoice->id, + 'amount' => $invoiceService->getTotalWithTax($invoice), + 'payer_email' => $invoice->buyer_email, + 'description' => $description, + 'success_redirect_url' => $thankyou_url, + 'failure_redirect_url' => $invoice_url, + 'currency' => $invoice->currency, + ]; + + if ($this->config['enable_logging']) { + $this->logger->info('Creating Xendit invoice with data: ' . json_encode($data)); + } + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, 'https://api.xendit.co/v2/invoices'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Authorization: Basic ' . base64_encode($this->getApiKey() . ':') + ]); + + $response = curl_exec($ch); + if (curl_errno($ch)) { + if ($this->config['enable_logging']) { + $this->logger->error('Xendit API Error: ' . curl_error($ch)); + } + throw new Payment_Exception('Error creating Xendit invoice: ' . curl_error($ch)); + } + curl_close($ch); + + $result = json_decode($response, true); + if (!isset($result['invoice_url'])) { + if ($this->config['enable_logging']) { + $this->logger->error('Xendit API Error: ' . $response); + } + throw new Payment_Exception('Invalid response from Xendit: ' . $response); + } + + if ($this->config['enable_logging']) { + $this->logger->info('Xendit invoice created: ' . json_encode($result)); + } + + return $result; + } + + private function createDetailedDescription($invoice, $items) + { + $description = "Invoice " . $invoice->serie . $invoice->nr; + + foreach ($items as $item) { + $description .= " | " . $item['title']; + } + + if (strlen($description) > 255) { + $description = substr($description, 0, 252) . '...'; + } + + return $description; + } + + private function verifyWebhookToken($token) + { + return hash_equals($this->getWebhookToken(), $token); + } + + + private function getApiKey() + { + return $this->config['use_sandbox'] ? $this->config['sandbox_api_key'] : $this->config['api_key']; + } + + + private function getWebhookToken() + { + return $this->config['use_sandbox'] ? $this->config['sandbox_webhook_token'] : $this->config['webhook_token']; + } +} \ No newline at end of file diff --git a/src/Xendit.png b/src/Xendit.png new file mode 100644 index 0000000..2192cb5 Binary files /dev/null and b/src/Xendit.png differ