diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..bd0eeb36 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/base:0-focal + +RUN apt-get update && \ + apt-get install -y software-properties-common && \ + add-apt-repository ppa:ondrej/php -y && \ + apt-get update && \ + apt-get install -y \ + php7.4 php7.4-cli php7.4-common php7.4-curl \ + php7.4-mysql php7.4-bcmath php7.4-soap php7.4-zip php7.4-intl \ + php7.4-gd php7.4-xsl php7.4-dom php7.4-mbstring \ + unzip && \ + rm -rf /var/lib/apt/lists/* + +RUN curl -sS https://getcomposer.org/installer | php -- \ + --install-dir=/usr/local/bin --filename=composer \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..ab573c27 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "Ona", + "build": { + "context": ".", + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/node:2": {}, + "ghcr.io/devcontainers/features/python:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:3.0.1": {} + } +} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..a01a5bef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +Have you read our [Code of Conduct](https://github.com/Flutterwave/PHP/blob/master/CONTRIBUTING.md)? By filing an Issue, you are expected to comply with it, including treating everyone with respect. + +# Description + + +# Steps to Reproduce + +1. +2. +3. + +## Expected behaviour + + +## Actual behaviour + + +## Reproduces how often + + +# Configuration +- API Version: +- Environment: +- Browser: +- Language: + +# Additional Information + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c72eb333 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Developer Support Forum + url: https://forum.flutterwave.com + about: If you're having general trouble with your integration, Kindly contact our support team. diff --git a/.github/workflows/artifact-release.yml b/.github/workflows/artifact-release.yml index 05fb2b2e..7c4a47e5 100644 --- a/.github/workflows/artifact-release.yml +++ b/.github/workflows/artifact-release.yml @@ -8,6 +8,8 @@ on: jobs: build: runs-on: ubuntu-latest + permissions: + contents: write strategy: matrix: @@ -30,6 +32,20 @@ jobs: id: tag run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV" + - name: Inject SigNoz API key + env: + SIGNOZ_API_KEY: ${{ secrets.SIGNOZ_API_KEY }} + run: | + if [ -z "$SIGNOZ_API_KEY" ]; then + echo "::error::SIGNOZ_API_KEY secret is not set" + exit 1 + fi + sed -i "s|%%SIGNOZ_API_KEY%%|${SIGNOZ_API_KEY}|g" src/Monitoring/SignozServiceLogger.php + if grep -q '%%SIGNOZ_API_KEY%%' src/Monitoring/SignozServiceLogger.php; then + echo "::error::Placeholder replacement failed" + exit 1 + fi + - name: Create release artifact run: | mkdir -p build diff --git a/.github/workflows/change-review.yml b/.github/workflows/change-review.yml index 8d3895ca..6ddc464e 100644 --- a/.github/workflows/change-review.yml +++ b/.github/workflows/change-review.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: true matrix: - php: [7.4, 8.1, 8.2, 8.3] + php: [7.4, 8.1, 8.2, 8.3, 8.4] env: XDEBUG_MODE: coverage @@ -22,6 +22,7 @@ jobs: SECRET_KEY: ${{ secrets.SECRET_KEY }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} ENV: ${{ secrets.ENV }} + SIGNOZ_API_KEY: ${{ secrets.SIGNOZ_API_KEY }} steps: - uses: actions/checkout@v3 @@ -48,6 +49,20 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress + - name: Inject SigNoz API key + env: + SIGNOZ_API_KEY: ${{ secrets.SIGNOZ_API_KEY }} + run: | + if [ -z "$SIGNOZ_API_KEY" ]; then + echo "::error::SIGNOZ_API_KEY secret is not set" + exit 1 + fi + sed -i "s|%%SIGNOZ_API_KEY%%|${SIGNOZ_API_KEY}|g" src/Monitoring/SignozServiceLogger.php + if grep -q '%%SIGNOZ_API_KEY%%' src/Monitoring/SignozServiceLogger.php; then + echo "::error::Placeholder replacement failed" + exit 1 + fi + - name: run unit tests and coverage scan run: ./vendor/bin/pest --coverage --min=20 --coverage-clover ./coverage.xml env: @@ -55,7 +70,6 @@ jobs: SECRET_KEY: ${{ secrets.SECRET_KEY }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} ENV: ${{ secrets.ENV }} - - name: Upload to Codecov uses: codecov/codecov-action@v2 with: diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml index 5d005d22..65df0196 100644 --- a/.github/workflows/package-publish.yml +++ b/.github/workflows/package-publish.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: true matrix: - php: [7.4, 8.1, 8.2] + php: [7.4, 8.1, 8.2, 8.3, 8.4] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2463140a..24856f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ -## 1.0.6 | 2023-24-07 +## 1.1.0 | 2025-04-10 +Performance enhancements, feature updates and bugfixes. + +Changes include: +- [ADDED] Test for card Preauthorization, ENAIRA, FAWRYPAY and GOOGLEPAY. +- [FIXED] Removed enums to support PHP v7.4 and above. +- [UPDATED] Code refactor. + +## 1.0.6 | 2023-07-24 Performance Optimization and Feature Update. + +Changes include: - [FIXED] RequeryPayment method on checkout - [ADDED] Support for TANZANIA MOBILE MONEY - [ADDED] Support for FAWRY PAY @@ -8,18 +18,16 @@ Performance Optimization and Feature Update. - [UPDATED] Simplified the Custom Configuration Contract to allow for easy configuration with the package. - [SECURITY & PERF] Checkout Process and Callback in processPayment.php -## 1.0.5 | 2023-24-01 +## 1.0.5 | 2023-01-24 This change allows the package to automatically detect .env file and use the variables declared in them. -### composer updates and bugfixes +Changes include: - [UPDATE] Log files generated in project directory. - [REFACTOR] remove unused files from distribution. -## 1.0.4 | 2022-11-06 - -This release adds support for 7.4 and above. a new workflow for old and new tests. - -### Dependency updates and bugfixes +## 1.0.4 | 2022-06-11 +This release adds support for PHP version 7.4 and above, as well as a new workflow for old and new tests. +Changes include: - [ADDED] Support for PHP 7.4 and above -- [ADDED] New workflow for old and new tests \ No newline at end of file +- [ADDED] New workflow for old and new tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..e0fe6a0e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +

+ +

+ +Thank you for taking the time to contribute to our library🙌🏾. + +In this section, we detail everything you need to know about contributing to this library. + + +**[Code of Conduct](https://github.com/probot/template/blob/master/CODE_OF_CONDUCT.md)** + +## **I don't want to contribute, I have a question** + +Please don't raise an issue to ask a question. You can ask questions on our [forum](http://forum.flutterwave.com) or developer [slack](https://bit.ly/34Vkzcg). We have an army of Engineers on hand to answer your questions there. + +## How can I contribute? + +### Reporting a bug + +Have you spotted a bug? Fantastic! Before raising an issue, here are some things to do: + +1. Search to see if another user has reported the bug. For existing issues that are still open, add a comment instead of creating a new one. +2. Check our forum and developer slack to confirm that we did not address it there. + +When you report an issue, it is important to: + +1. Explain the problem + - Use a clear and descriptive title to help us to identify the problem. + - Describe steps we can use to replicate the bug and be as precise as possible. + - Include screenshots of the error messages. +2. Include details about your configuration and setup + - What version of the library are you using? + - Did you experience the bug on test mode or live? + - Do you have the recommended versions of the library dependencies? + + + +### Requesting a feature + +If you need an additional feature added to the library, kindly send us an email at developers@flutterwavego.com. Be sure to include the following in your request: + +1. A clear title that helps us to identify the requested feature. +2. A brief description of the use case for that feature. +3. Explain how this feature would be helpful to your integration. +4. Library name and version. + +### Submitting changes (PR) + +Generally, you can make any of the following changes to the library: + +1. Bug fixes +2. Performance improvement +3. Documentation update +4. Functionality change (usually new features) + + + +Follow these steps when making a pull request to the library: + +1. Fork the repository and create your branch from master. +2. For all types of changes (excluding documentation updates), add tests for the changes. +3. If you are making a functionality change, update the docs to show how to use the new feature. +4. Ensure all your tests pass. +5. Make sure your code lints. +6. Write clear log messages for your commits. one-liners are fine for small changes, but bigger changes should have a more descriptive commit message (see sample below). +7. Use present tense for commit messages, "Add feature" not "Added feature”. +8. Ensure that you fill out all sections of the PR template. +9. Raise the PR against the `staging` branch. +10. After you submit the PR, verify that all [status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) are passing + +```markdown +$ git commit -m "A brief summary of the commit +> +> A paragraph describing what changed and its impact." +``` + + + +We encourage you to contribute and help make the library better for the community. Got questions? send us a [message](https://bit.ly/34Vkzcg). + +Thank you. + +The Flutterwave team 🦋 diff --git a/README.md b/README.md index 6a89584e..7719dffb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ If you do not want to make use of composer. each [release](https://github.com/Fl ### Installation via Composer. -To install the package via Composer, run the following command. +To install the package via Composer, run the following command: ```shell composer require flutterwavedev/flutterwave-v3 ``` @@ -67,7 +67,7 @@ Save your PUBLIC_KEY, SECRET_KEY, ENV in the `.env` file ```bash cp .env.example .env ``` -Your `.env` file should look this. +Your `.env` file should look this. Make sure to retrieve your API keys from your dashboard. ```env FLW_PUBLIC_KEY=FLWSECK_TEST-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-X diff --git a/composer.json b/composer.json index dc3a8cd4..e80a995e 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "mockery/mockery": ">=1.2", "symfony/var-dumper": "5.4.13", "phpstan/phpstan": "^1.9", - "pestphp/pest": "^1.22", + "pestphp/pest": "1.22", "nunomaduro/phpinsights": "^2.6", "eloquent/liberator": "^3.0", "squizlabs/php_codesniffer": "3.*", @@ -45,6 +45,9 @@ } ], "config": { + "platform": { + "php": "7.4" + }, "allow-plugins": { "pestphp/pest-plugin": true, "dealerdirect/phpcodesniffer-composer-installer": true diff --git a/paymentForm.php b/paymentForm.php index 34bb9d59..1090588f 100644 --- a/paymentForm.php +++ b/paymentForm.php @@ -43,9 +43,9 @@ - + - +
diff --git a/processPayment.php b/processPayment.php index 0946145a..b595865b 100644 --- a/processPayment.php +++ b/processPayment.php @@ -11,7 +11,7 @@ use Flutterwave\Library\Modal; use \Flutterwave\Config\ForkConfig; -// start a session. +// start a session for redirect metadata. session_start(); // Define custom config. @@ -39,9 +39,15 @@ $controller = new PaymentController( $client, $customHandler, $modalType ); } catch(\Exception $e ) { echo $e->getMessage(); + exit(); } if ($_SERVER["REQUEST_METHOD"] === "POST") { + if ($controller === null) { + echo 'Unable to initialize payment controller.'; + exit(); + } + $request = $_REQUEST; $request['redirect_url'] = $_SERVER['HTTP_ORIGIN'] . $_SERVER['REQUEST_URI']; try { @@ -54,6 +60,11 @@ $request = $_GET; # Confirming Payment. if(isset($request['tx_ref'])) { + if ($controller === null) { + echo 'Unable to initialize payment controller.'; + exit(); + } + $controller->callback( $request ); } else { diff --git a/src/AbstractPayment.php b/src/AbstractPayment.php index b067c6a7..28b25345 100644 --- a/src/AbstractPayment.php +++ b/src/AbstractPayment.php @@ -7,6 +7,7 @@ use Flutterwave\Contract\ConfigInterface; use Flutterwave\EventHandlers\EventHandlerInterface; use Flutterwave\Helper\EnvVariables; +use Flutterwave\Monitoring\SignozServiceLogger; use Flutterwave\Traits\ApiOperations as Api; use Flutterwave\Traits\PayloadOperations as Payload; use Psr\Log\LoggerInterface; diff --git a/src/Config/AbstractConfig.php b/src/Config/AbstractConfig.php index d0a2b1a7..3bb20160 100644 --- a/src/Config/AbstractConfig.php +++ b/src/Config/AbstractConfig.php @@ -7,6 +7,7 @@ use Flutterwave\EventHandlers\EventHandlerInterface; use Flutterwave\Flutterwave; use Flutterwave\Contract\ConfigInterface; +use Flutterwave\Monitoring\SignozServiceLogger; use Psr\Http\Client\ClientInterface; use Psr\Log\LoggerInterface; use Monolog\Logger; @@ -14,6 +15,8 @@ use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; use Flutterwave\Helper\EnvVariables; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; abstract class AbstractConfig { @@ -21,11 +24,12 @@ abstract class AbstractConfig public const SECRET_KEY = 'SECRET_KEY'; public const ENCRYPTION_KEY = 'ENCRYPTION_KEY'; public const ENV = 'ENV'; - public const DEFAULT_PREFIX = 'FW|PHP'; + public const DEFAULT_PREFIX = 'FW_PHP'; public const LOG_FILE_NAME = 'flutterwave-php.log'; public Logger $logger; protected string $secret; protected string $public; + public SignozServiceLogger $signoz; protected static ?ConfigInterface $instance = null; protected string $env; @@ -53,6 +57,10 @@ protected function __construct(string $secret_key, string $public_key, string $e $log = new Logger('Flutterwave/PHP'); $this->logger = $log; + $cache = new Psr16Cache(new FilesystemAdapter('flutterwave_signoz')); + $this->signoz = new SignozServiceLogger($this->http, $this->getPublicKey(), $this->getEnv(), $cache, EnvVariables::SDK_VERSION); + // Track app initialization once per lifecycle + $this->signoz->trackAppCreated($this->getPublicKey()); } abstract public static function setUp( @@ -84,4 +92,9 @@ public static function getDefaultTransactionPrefix(): string { return self::DEFAULT_PREFIX; } + + public function getSignoz(): SignozServiceLogger + { + return $this->signoz; + } } diff --git a/src/Contract/ConfigInterface.php b/src/Contract/ConfigInterface.php index 8fcaac69..fdd9b5ad 100644 --- a/src/Contract/ConfigInterface.php +++ b/src/Contract/ConfigInterface.php @@ -4,6 +4,7 @@ namespace Flutterwave\Contract; +use Flutterwave\Monitoring\SignozServiceLogger; use Psr\Http\Client\ClientInterface; use Psr\Log\LoggerInterface; diff --git a/src/Controller/PaymentController.php b/src/Controller/PaymentController.php index 00209f8f..b9e861d4 100644 --- a/src/Controller/PaymentController.php +++ b/src/Controller/PaymentController.php @@ -4,10 +4,8 @@ namespace Flutterwave\Controller; -use Flutterwave\EventHandlers\ModalEventHandler; use Flutterwave\EventHandlers\EventHandlerInterface; use Flutterwave\Flutterwave; -use Flutterwave\Entities\Payload; use Flutterwave\Library\Modal; use Flutterwave\Service\Transactions; @@ -43,14 +41,14 @@ private function getRequestMethod(): string public function __call(string $name, array $args) { - if ($this->routes[$name] !== $this->$requestMethod) { + if ($this->routes[$name] !== $this->requestMethod) { // Todo: 404(); echo "Unauthorized page!"; } - call_user_method_array($name, $this, $args); + call_user_func_array([$this, $name], $args); } - private function handleSessionData( array $request ) + private function handleSessionData(array $request): void { $_SESSION['success_url'] = $request['success_url']; $_SESSION['failure_url'] = $request['failure_url']; @@ -61,10 +59,8 @@ private function handleSessionData( array $request ) public function process(array $request) { $this->handleSessionData($request); - - try { - $_SESSION['p'] = $this->client; + try { if('inline' === $this->modalType ) { echo $this->client ->eventHandler($this->handler) @@ -87,23 +83,11 @@ public function callback(array $request) $status = $request['status']; if (empty($tx_ref)) { - session_destroy(); - } - - if (!isset($_SESSION['p'])) { - echo "session expired!. please refresh you browser."; + echo 'Missing transaction reference.'; exit(); } - $payment = $_SESSION['p']; - - // $payment::setUp([ - // 'secret_key' => 'FLWSECK_TEST-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-X', - // 'public_key' => 'FLWPUBK_TEST-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-X', - // 'encryption_key' => 'FLWSECK_XXXXXXXXXXXXXXXX', - // 'environment' => 'staging' - // ]); - + $payment = $this->client; $payment::bootstrap(); if ('cancelled' === $status) { diff --git a/src/EventHandlers/ModalEventHandler.php b/src/EventHandlers/ModalEventHandler.php index ae5fcb88..d53dbd7e 100644 --- a/src/EventHandlers/ModalEventHandler.php +++ b/src/EventHandlers/ModalEventHandler.php @@ -4,8 +4,13 @@ namespace Flutterwave\EventHandlers; +use Flutterwave\Service\Transactions; + class ModalEventHandler implements EventHandlerInterface { + private ?string $success_url = null; + private ?string $failure_url = null; + /** * This is called when the Rave class is initialized * */ @@ -15,6 +20,26 @@ public function onInit($initializationData): void // Save the transaction to your DB. } + public function getSuccessUrl(): ?string + { + return $this->success_url; + } + + public function setSuccessUrl(?string $success_url): void + { + $this->success_url = $success_url; + } + + public function getFailureUrl(): ?string + { + return $this->failure_url; + } + + public function setFailureUrl(?string $failure_url): void + { + $this->failure_url = $failure_url; + } + /** * This is called only when a transaction is successful * */ @@ -31,26 +56,34 @@ public function onSuccessful($transactionData): void // Update the transaction to note that you have given value for the transaction. // You can also redirect to your success page from here. if ($transactionData->status === 'successful') { - $currency = $_SESSION['currency']; - $amount = $_SESSION['amount']; + $currency = $_SESSION['currency'] ?? ($transactionData->currency ?? null); + $amount = $_SESSION['amount'] ?? ($transactionData->amount ?? null); - if ($transactionData->currency === $currency && floatval($transactionData->amount) === floatval($amount)) { - header('Location: ' . $_SESSION['success_url']); - session_destroy(); - } + if ($currency !== null && $amount !== null) { + if ($transactionData->currency === $currency && floatval($transactionData->amount) === floatval($amount)) { + if (!empty($_SESSION['success_url'])) { + header('Location: ' . $_SESSION['success_url']); + } + session_destroy(); + } - if ($transactionData->currency === $currency && floatval($transactionData->amount) < floatval($amount)) { - // TODO: replace this a custom action. - echo "This Event Handler is an Implementation of " . __NAMESPACE__ . "\EventHandlerInterface
"; - echo "Partial Payment Made ! replace this with your own action! "; - session_destroy(); - } + if ($transactionData->currency === $currency && floatval($transactionData->amount) < floatval($amount)) { + // TODO: replace this a custom action. + echo "This Event Handler is an Implementation of " . __NAMESPACE__ . "\EventHandlerInterface
"; + echo "Partial Payment Made ! replace this with your own action! "; + session_destroy(); + } - if ($transactionData->currency !== $currency && floatval($transactionData->amount) === floatval($amount)) { - // TODO: replace this a custom action. + if ($transactionData->currency !== $currency && floatval($transactionData->amount) === floatval($amount)) { + // TODO: replace this a custom action. + echo "This Event Handler is an Implementation of " . __NAMESPACE__ . "\EventHandlerInterface
"; + echo "Currency mismatch. please look into it ! replace this with your own action "; + session_destroy(); + } + } else { + // Fallback when session metadata is unavailable. echo "This Event Handler is an Implementation of " . __NAMESPACE__ . "\EventHandlerInterface
"; - echo "Currency mismatch. please look into it ! replace this with your own action "; - session_destroy(); + echo "Transaction successful."; } } else { $this->onFailure($transactionData); @@ -66,7 +99,9 @@ public function onFailure($transactionData): void // Update the db transaction record (includeing parameters that didn't exist before the transaction is completed. for audit purpose) // You can also redirect to your failure page from here. // TODO: replace this a custom action. - header('Location: ' . $_SESSION['failure_url']); + if (!empty($_SESSION['failure_url'])) { + header('Location: ' . $_SESSION['failure_url']); + } session_destroy(); } @@ -85,8 +120,12 @@ public function onRequeryError($requeryResponse): void { echo "Flutterwave: error querying the transaction."; // trigger webhook notification from Flutterwave. - $service = new Flutterwave\Service\Transaction(); - $service->resendFailedHooks($data->id); + $service = new Transactions(); + $transactionId = is_object($requeryResponse) && isset($requeryResponse->id) + ? (string) $requeryResponse->id + : (string) $requeryResponse; + + $service->resendFailedHooks($transactionId); header('Location: ' . $_SERVER['HTTP_ORIGIN']); } @@ -107,8 +146,8 @@ public function onCancel($transactionReference): void public function onTimeout($transactionReference, $data): void { // trigger webhook notification from Flutterwave. - $service = new Flutterwave\Service\Transaction(); - $service->resendFailedHooks($data->id); + $service = new Transactions(); + $service->resendFailedHooks((string) ($data->id ?? '')); header('Location: ' . $_SERVER['HOST']); } } diff --git a/src/Flutterwave.php b/src/Flutterwave.php index 01d18251..b62ce7fc 100644 --- a/src/Flutterwave.php +++ b/src/Flutterwave.php @@ -8,6 +8,7 @@ use Flutterwave\EventHandlers\EventHandlerInterface; use Flutterwave\Exception\ApiException; use Flutterwave\Helper\CheckCompatibility; +use Flutterwave\Monitoring\SignozServiceLogger; use Flutterwave\Traits\PaymentFactory; use Flutterwave\Traits\Setup\Configure; use Flutterwave\Library\Modal; @@ -27,11 +28,12 @@ class Flutterwave extends AbstractPayment use Configure; use PaymentFactory; + private SignozServiceLogger $signoz; + /** * Flutterwave Construct * - * @param string $prefix - * @param bool $overrideRefWithPrefix Set this parameter to true to use your prefix as the transaction reference + * @throws \Exception */ public function __construct() { @@ -41,6 +43,12 @@ public function __construct() $this->logger = self::$config->getLoggerInstance(); $this->createReferenceNumber(); $this->logger->notice('Main Class Initializes....'); + + if (!method_exists(self::$config, 'getSignoz')) { + $this->signoz = self::getSignoz(); + } else { + $this->signoz = self::$config->getSignoz(); + } } private function checkPageIsSecure() @@ -75,7 +83,7 @@ public function setPaymentOptions(string $paymentOptions): object /** * get event handler. * - * @param string $paymentOptions The allowed payment methods. Can be card, account or both + * @return EventHandlerInterface */ public function getEventHandler() { @@ -247,6 +255,9 @@ public function requeryTransaction(string $referenceNumber): object $this->handler->onRequery($this->txref); } + $appId = $this->signoz->getAppId(); + $environment = $this->signoz->getCurrentEnvironment(); + $data = [ 'id' => (int) $referenceNumber, // 'only_successful' => '1' @@ -262,10 +273,19 @@ public function requeryTransaction(string $referenceNumber): object $this->logger->notice('Requeryed a successful transaction....' . json_encode($response->data)); // Handle successful. if (isset($this->handler)) { + $final_tx_ref = $response->data->tx_ref; + $this->signoz->trackRequestSent($appId, $environment, 'GET', $referenceNumber, $url ); + if( 'production' === $environment ) { + $final_currency = $response->data->currency; + $final_amount = $response->data->amount; + $payment_type = $response->data->payment_type; + $final_fee = $response->data->app_fee; + $this->signoz->trackTransaction($appId,$final_tx_ref, $final_currency, (float) $final_amount, $payment_type, (float) $final_fee); + } $this->handler->onSuccessful($response->data); } } elseif ($response->data && $response->data->status === 'failed') { - // Handle Failure + // Handle Failure. $this->logger->warning('Requeryed a failed transaction....' . json_encode($response->data)); if (isset($this->handler)) { $this->handler->onFailure($response->data); @@ -280,6 +300,7 @@ public function requeryTransaction(string $referenceNumber): object if ($this->requeryCount > 4) { // Now you have to setup a queue by force. We couldn't get a status in 5 requeries. if (isset($this->handler)) { + $this->signoz->trackError($appId, 'TIMEOUT_ERROR', 'timedout while requerying transaction with id: ' . $referenceNumber); $this->handler->onTimeout($this->txref, $response->data); } } else { @@ -290,7 +311,8 @@ public function requeryTransaction(string $referenceNumber): object } } } else { - // Handle Requery Error + // Handle Requery Error. + $this->signoz->trackError($appId, 'REQUERY_ERROR', 'Failed to requery transaction with id: ' . $referenceNumber); if (isset($this->handler)) { $this->handler->onRequeryError($response->data); } @@ -305,6 +327,11 @@ public function initialize(): void { $this->createCheckSum(); + $appId = $this->signoz->getAppId(); + $environment = $this->signoz->getCurrentEnvironment(); + + $this->signoz->trackRequestSent($appId, $environment, 'GET', $this->txref, '/inline'); + $this->logger->info('Rendering Payment Modal..'); echo ''; diff --git a/src/Helper/Config.php b/src/Helper/Config.php index 54b11418..ecd62762 100644 --- a/src/Helper/Config.php +++ b/src/Helper/Config.php @@ -24,7 +24,7 @@ class Config implements ConfigInterface public const SECRET_KEY = 'SECRET_KEY'; public const ENCRYPTION_KEY = 'ENCRYPTION_KEY'; public const ENV = 'ENV'; - public const DEFAULT_PREFIX = 'FW|PHP'; + public const DEFAULT_PREFIX = 'FW_PHP'; public const LOG_FILE_NAME = 'flutterwave-php.log'; protected Logger $logger; private string $secret; diff --git a/src/Library/Modal.php b/src/Library/Modal.php index f6273a7d..bd90d16c 100644 --- a/src/Library/Modal.php +++ b/src/Library/Modal.php @@ -10,6 +10,7 @@ use Flutterwave\EventHandlers\EventHandlerInterface; use Flutterwave\Helper\CheckoutHelper; +use Flutterwave\Monitoring\SignozServiceLogger; use Flutterwave\Service\Service as Http; use Flutterwave\Entities\Payload; use Psr\Log\LoggerInterface; @@ -107,6 +108,11 @@ public function getHtml() return $this->returnUrl(); } + /** @var SignozServiceLogger $signoz */ + $signoz = self::$config->getSignoz(); + $appId = $signoz->getAppId(); + $environment = $signoz->getCurrentEnvironment(); + $default_options = CheckoutHelper::getDefaultPaymentOptions(); $payload = $this->payload->toArray('modal'); @@ -151,6 +157,7 @@ public function getHtml() $html .= ''; $this->logger->info('Rendered Payment Modal Successfully..'); + $signoz->trackRequestSent($appId, $environment, 'GET', $payload['tx_ref'], '/inline'); return $html; } @@ -161,6 +168,11 @@ public function getUrl() return $this->returnHtml(); } + /** @var SignozServiceLogger $signoz */ + $signoz = self::$config->getSignoz(); + $appId = $signoz->getAppId(); + $environment = $signoz->getCurrentEnvironment(); + $default_options = CheckoutHelper::getDefaultPaymentOptions(); $payload = $this->payload->toArray('modal'); $currency = $payload['currency']; @@ -172,6 +184,7 @@ public function getUrl() $payload['customer']['name'] = $payload['customer']['fullname']; $this->logger->info('Generating Payment link for [' . $payload['tx_ref'] . ']'); + $signoz->trackRequestSent($appId, $environment, 'GET', $payload['tx_ref'], '/payments'); $response = (new Http(self::$config))->request($payload, 'POST', 'payments'); return $response->data->link; } diff --git a/src/Monitoring/SignozServiceLogger.php b/src/Monitoring/SignozServiceLogger.php new file mode 100644 index 00000000..5d601eec --- /dev/null +++ b/src/Monitoring/SignozServiceLogger.php @@ -0,0 +1,454 @@ +httpClient = $httpClient; + $this->cache = $cache; + $this->libraryVersion = $libraryVersion; + $this->publicKey = $publicKey; + $this->environment = $environment; + } + + public function getAppId() { + if (!empty($this->appId)) { + return $this->appId; + } + + $merchantId = $this->getMerchantId($this->publicKey); + if (!empty($merchantId)) { + $this->appId = $this->normalizeAppId($merchantId); + return $this->appId; + } + return $this->normalizeAppId($this->publicKey); + } + + public function getCurrentEnvironment(): string + { + return $this->environment !== 'production' ? 'sandbox' : 'production'; + } + + public function getMerchantId(string $publicKey) { + try { + $response = $this->httpClient->request('GET', self::MERCHANT_INFO . $publicKey, [ + 'headers' => [ + 'Content-Type' => 'application/json' + ] + ]); + + $result = json_decode($response->getBody()->getContents(), true); + + if(!empty($result) && isset($result['mn'])) { + return $result['mn']; + } + } catch (\Throwable $e) { + // observability must never break payments + } + return null; + } + + public function trackAppCreated( + string $publicKey + ): void { + $cacheKey = sprintf('signoz:app_created:%s', hash('sha256', $publicKey)); + + if (self::$appCreatedSent) { + return; + } + + if ($this->cache !== null) { + try { + if ($this->cache->has($cacheKey)) { + self::$appCreatedSent = true; + return; + } + } catch (\Throwable $e) { + // observability must never break payments + } + } + + $merchantId = $this->getMerchantId($publicKey); + + if (empty($merchantId)) { + return; + } + + $this->send('app.created', [ + 'app_id' => $this->normalizeAppId($merchantId), + 'client_id' => null, + 'public_key' => $publicKey, + 'library' => self::LIBRARY, + 'library_version' => $this->libraryVersion, + ]); + + if ($this->cache !== null) { + try { + $this->cache->set($cacheKey, true); + } catch (\Throwable $e) { + // observability must never break payments + } + } + + self::$appCreatedSent = true; + } + + public function trackRequestSent( + string $appId, + string $environment, + string $method, + string $reference, + string $path + ): void { + $safeReference = $this->normalizeReference($reference); + + $payload = [ + 'app_id' => $this->normalizeAppId($appId), + 'environment' => $environment, + 'api_version' => EnvVariables::VERSION, + 'library_version' => $this->libraryVersion, + 'method' => $method, + 'path' => $path, + 'reference' => $safeReference, + ]; + + $cacheKey = sprintf( + 'signoz:request_sent:%s', + $safeReference + ); + + if ($this->cache !== null) { + + try { + if ($this->cache->has($cacheKey)) { + return; + } + + $this->cache->set($cacheKey, true, 300); + } catch (\Throwable $e) { + // observability must never break payments + } + } + + $this->send('request.sent', $payload); + } + + public function trackTransaction( + string $appId, + string $reference, + string $currency, + float $amount, + string $method, + float $fee + ): void { + $this->send('app.transaction', [ + 'app_id' => $this->normalizeAppId($appId), + 'reference' => $reference, + 'currency' => $currency, + 'amount' => $amount, + 'fee' => $fee, + 'method' => $method, + ]); + } + + public function trackError( + string $appId, + string $errorCode, + string $errorMessage + ): void { + $this->send('app.error', [ + 'app_id' => $this->normalizeAppId($appId), + 'library' => self::LIBRARY, + 'library_version' => $this->libraryVersion, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + ]); + } + + private function send(string $eventName, array $data): void + { + try { + // 1. Circuit breaker gate: if open, drop the event immediately. + if ($this->isCircuitOpen()) { + return; + } + + // 2. Health gate: verify /health/ready (cached for HEALTH_CACHE_TTL). + // When the circuit has just moved out of cooldown, this acts as + // the half-open probe before real traffic resumes. + if (!$this->isServiceHealthy()) { + $this->recordFailure(); + return; + } + + $this->sendWithRetry($eventName, $data); + } catch (\Throwable $e) { + // observability must never break payments + } + } + + private function sendWithRetry(string $eventName, array $data): void + { + $body = [ + 'name' => $eventName, + 'data' => $data, + 'timestamp' => gmdate('Y-m-d\TH:i:s.000\Z'), + ]; + + for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) { + try { + $this->httpClient->request('POST', self::BASE_URL . '/events', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'x-api-key' => self::API_KEY, + ], + 'json' => $body, + + // fire-and-forget-ish + 'timeout' => 2, + 'connect_timeout' => 1, + ]); + + $this->recordSuccess(); + return; + } catch (RequestException $e) { + $response = $e->getResponse(); + $statusCode = $response !== null ? $response->getStatusCode() : 0; + + // Only 503 (service temporarily unavailable) is retried. + if ($statusCode === 503 && $attempt < self::MAX_ATTEMPTS) { + $this->backoffSleep($attempt); + continue; + } + + $this->recordFailure(); + return; + } catch (\Throwable $e) { + // Network/transport errors: count as a failure, never throw. + $this->recordFailure(); + return; + } + } + + // Exhausted retries (all 503s). + $this->recordFailure(); + } + + /** + * Exponential backoff with full jitter: + * sleep for random(0, min(MAX_DELAY, BASE * 2^(attempt-1))). + */ + private function backoffSleep(int $attempt): void + { + $ceilingMs = min( + self::MAX_DELAY_MS, + self::BASE_DELAY_MS * (2 ** ($attempt - 1)) + ); + + try { + $delayMs = random_int(0, $ceilingMs); + } catch (\Throwable $e) { + $delayMs = $ceilingMs; + } + + usleep($delayMs * 1000); + } + + /** + * GET /health/ready and require {"status":"ok"}. + * A passing check is cached so we don't probe on every event. + */ + private function isServiceHealthy(): bool + { + $now = time(); + + // Trust a recent successful health check. + if ($this->cache !== null) { + try { + if ($this->cache->has(self::HEALTH_OK_KEY)) { + return true; + } + } catch (\Throwable $e) { + // fall through to live probe + } + } elseif (self::$staticHealthyUntil > $now) { + return true; + } + + try { + $response = $this->httpClient->request('GET', self::BASE_URL . self::HEALTH_PATH, [ + 'headers' => [ + 'x-api-key' => self::API_KEY, + ], + 'timeout' => 1, + 'connect_timeout' => 1, + ]); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $result = json_decode($response->getBody()->getContents(), true); + + $healthy = is_array($result) + && isset($result['status']) + && $result['status'] === 'ok'; + + if ($healthy) { + if ($this->cache !== null) { + try { + $this->cache->set(self::HEALTH_OK_KEY, true, self::HEALTH_CACHE_TTL); + } catch (\Throwable $e) { + // observability must never break payments + } + } else { + self::$staticHealthyUntil = $now + self::HEALTH_CACHE_TTL; + } + } + + return $healthy; + } catch (\Throwable $e) { + return false; + } + } + + // --- Circuit breaker state ------------------------------------------- + + private function isCircuitOpen(): bool + { + $now = time(); + + if ($this->cache !== null) { + try { + $openUntil = (int) ($this->cache->get(self::CB_OPEN_UNTIL_KEY, 0)); + return $openUntil > $now; + } catch (\Throwable $e) { + // fall back to in-process state + } + } + + return self::$staticOpenUntil > $now; + } + + private function recordSuccess(): void + { + self::$staticFailureCount = 0; + self::$staticOpenUntil = 0; + + if ($this->cache !== null) { + try { + $this->cache->delete(self::CB_FAILURES_KEY); + $this->cache->delete(self::CB_OPEN_UNTIL_KEY); + } catch (\Throwable $e) { + // observability must never break payments + } + } + } + + private function recordFailure(): void + { + $failures = ++self::$staticFailureCount; + + if ($this->cache !== null) { + try { + $failures = (int) $this->cache->get(self::CB_FAILURES_KEY, 0) + 1; + $this->cache->set(self::CB_FAILURES_KEY, $failures, self::CB_OPEN_TTL * 2); + } catch (\Throwable $e) { + // keep in-process count + } + } + + if ($failures >= self::CB_FAILURE_THRESHOLD) { + $this->openCircuit(); + } + } + + private function openCircuit(): void + { + $openUntil = time() + self::CB_OPEN_TTL; + + self::$staticOpenUntil = $openUntil; + self::$staticFailureCount = 0; + + if ($this->cache !== null) { + try { + $this->cache->set(self::CB_OPEN_UNTIL_KEY, $openUntil, self::CB_OPEN_TTL); + $this->cache->delete(self::CB_FAILURES_KEY); + + // Invalidate the cached health status so the next attempt + // after cooldown re-probes /health/ready (half-open behavior). + $this->cache->delete(self::HEALTH_OK_KEY); + } catch (\Throwable $e) { + // observability must never break payments + } + } + + self::$staticHealthyUntil = 0; + } + + private function normalizeAppId(string $appId): string + { + return preg_replace('/\s+/', '-', trim($appId)) ?? $appId; + } + + private function normalizeReference(string $reference): string + { + $normalized = preg_replace('/[^A-Za-z0-9_-]+/', '-', trim($reference)); + + if ($normalized === null) { + return $reference; + } + + return trim($normalized, '-'); + } +} \ No newline at end of file diff --git a/src/Service/Service.php b/src/Service/Service.php index 0ad3760a..8a3797c8 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -12,6 +12,7 @@ use Flutterwave\Factories\PayloadFactory as Payload; use Flutterwave\Helper\Config; use Flutterwave\Helper\EnvVariables; +use Flutterwave\Monitoring\SignozServiceLogger; use Psr\Http\Client\ClientInterface; use InvalidArgumentException; use Psr\Http\Client\ClientExceptionInterface; @@ -27,6 +28,7 @@ class Service implements ServiceInterface public ?FactoryInterface $customer; protected string $baseUrl; protected LoggerInterface $logger; + protected SignozServiceLogger $signoz; protected ConfigInterface $config; protected string $url; protected string $secret; @@ -42,6 +44,7 @@ public function __construct(?ConfigInterface $config = null) $this->config = is_null($config) ? self::$spareConfig : $config; $this->http = $this->config->getHttp(); $this->logger = $this->config->getLoggerInstance(); + $this->signoz = $this->config->getSignoz(); $this->secret = $this->config->getSecretKey(); $this->url = EnvVariables::BASE_URL . '/'; $this->baseUrl = EnvVariables::BASE_URL; @@ -68,57 +71,70 @@ public function request( $secret = $this->config->getSecretKey(); $url = $this->getUrl($overrideUrl, $additionalurl); + $reference = $this->resolveSignozReference($data, $additionalurl, $verb); switch ($verb) { - case 'POST': - $response = $this->http->request( - 'POST', $url, [ - 'debug' => false, // TODO: turn to false on release. - 'headers' => [ - 'Authorization' => "Bearer $secret", - 'Content-Type' => 'application/json', - ], - 'json' => $data, + case 'POST': + $response = $this->http->request( + 'POST', + $url, + [ + 'debug' => false, // TODO: turn to false on release. + 'headers' => [ + 'Authorization' => "Bearer $secret", + 'Content-Type' => 'application/json', + ], + 'json' => $data, ] - ); - break; - case 'PUT': - $response = $this->http->request( - 'PUT', $url, [ - 'debug' => false, // TODO: turn to false on release. - 'headers' => [ - 'Authorization' => "Bearer $secret", - 'Content-Type' => 'application/json', - ], - 'json' => $data ?? [], + ); + break; + case 'PUT': + $response = $this->http->request( + 'PUT', + $url, + [ + 'debug' => false, // TODO: turn to false on release. + 'headers' => [ + 'Authorization' => "Bearer $secret", + 'Content-Type' => 'application/json', + ], + 'json' => $data ?? [], ] - ); - break; - case 'DELETE': - $response = $this->http->request( - 'DELETE', $url, [ - 'debug' => false, - 'headers' => [ - 'Authorization' => "Bearer $secret", - 'Content-Type' => 'application/json', - ], + ); + break; + case 'DELETE': + $response = $this->http->request( + 'DELETE', + $url, + [ + 'debug' => false, + 'headers' => [ + 'Authorization' => "Bearer $secret", + 'Content-Type' => 'application/json', + ], ] - ); - break; - default: - $response = $this->http->request( - 'GET', $url, [ - 'debug' => false, - 'headers' => [ - 'Authorization' => "Bearer $secret", - 'Content-Type' => 'application/json', - ], + ); + break; + default: + $response = $this->http->request( + 'GET', + $url, + [ + 'debug' => false, + 'headers' => [ + 'Authorization' => "Bearer $secret", + 'Content-Type' => 'application/json', + ], ] - ); - break; + ); + break; } $body = $response->getBody()->getContents(); + $appId = $this->signoz->getAppId(); + $environment = $this->signoz->getCurrentEnvironment(); + $this->signoz->trackRequestSent($appId, $environment, $verb, $reference, $additionalurl); + return json_decode($body); } @@ -127,7 +143,7 @@ protected function checkTransactionId($transactionId): void $pattern = '/([0-9]){7}/'; $is_valid = preg_match_all($pattern, $transactionId); - if (! $is_valid) { + if (!$is_valid) { $this->logger->warning('Transaction Service::cannot verify invalid transaction id. '); throw new InvalidArgumentException('cannot verify invalid transaction id.'); } @@ -166,4 +182,31 @@ private function getUrl(bool $overrideUrl, string $additionalurl): string return $this->url . $additionalurl; } + + private function resolveSignozReference(?array $data, string $additionalurl): string + { + if (!is_null($data) && isset($data['tx_ref'])) { + return (string) $data['tx_ref']; + } + + foreach (['reference', 'order_ref', 'batch_id', 'id'] as $key) { + if (!empty($data[$key])) { + return (string) $data[$key]; + } + } + + $segments = array_values(array_filter(explode('/', trim($additionalurl, '/')))); + + $segmentCount = count($segments); + + if ($segmentCount === 4) { + return $segments[2]; + } + + if ($segmentCount === 3) { + return $segments[1]; + } + + return implode('-', $segments); + } } diff --git a/src/Traits/Setup/Configure.php b/src/Traits/Setup/Configure.php index dcc7cf11..eb4df8dc 100644 --- a/src/Traits/Setup/Configure.php +++ b/src/Traits/Setup/Configure.php @@ -8,6 +8,10 @@ use Flutterwave\Helper\Config; use Flutterwave\Config\PackageConfig; use Flutterwave\Config\ForkConfig; +use Flutterwave\Helper\EnvVariables; +use Flutterwave\Monitoring\SignozServiceLogger; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Psr16Cache; trait Configure { @@ -41,4 +45,14 @@ public static function bootstrap(?ConfigInterface $config = null): void self::$methods = include __DIR__ . '/../../Util/methods.php'; } + + public static function getSignoz(): SignozServiceLogger + { + $https = self::$config->getHttp(); + $env = self::$config->getEnv(); + $publicKey = self::$config->getPublicKey(); + $cache = new Psr16Cache(new FilesystemAdapter('flutterwave_signoz')); + + return new SignozServiceLogger($https, $publicKey, $env, $cache, EnvVariables::SDK_VERSION); + } } diff --git a/tests/Resources/Setup/Config.php b/tests/Resources/Setup/Config.php index eea6a86a..f628ca0d 100644 --- a/tests/Resources/Setup/Config.php +++ b/tests/Resources/Setup/Config.php @@ -20,7 +20,7 @@ class Config implements ConfigInterface public const SECRET_KEY = 'SECRET_KEY'; public const ENCRYPTION_KEY = 'ENCRYPTION_KEY'; public const ENV = 'ENV'; - public const DEFAULT_PREFIX = 'FW|PHP'; + public const DEFAULT_PREFIX = 'FW_PHP'; public const LOG_FILE_NAME = 'flutterwave-php.log'; protected Logger $logger; private string $secret; diff --git a/tests/Unit/Monitoring/SignozServiceLoggerTest.php b/tests/Unit/Monitoring/SignozServiceLoggerTest.php new file mode 100644 index 00000000..e7a72bde --- /dev/null +++ b/tests/Unit/Monitoring/SignozServiceLoggerTest.php @@ -0,0 +1,371 @@ +resetStaticState(); + } + + protected function tearDown(): void + { + $this->resetStaticState(); + } + + // ----------------------------------------------------------------- + // Health check gate + // ----------------------------------------------------------------- + + public function testEventIsSentWhenServiceIsHealthy(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + return new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + $this->assertCount(2, $calls); + $this->assertSame(['GET', self::HEALTH_URL], [$calls[0]['method'], $calls[0]['uri']]); + $this->assertSame(['POST', self::EVENTS_URL], [$calls[1]['method'], $calls[1]['uri']]); + + // Health probe must carry the API key header. + $this->assertArrayHasKey('x-api-key', $calls[0]['options']['headers']); + + // Event payload sanity check. + $this->assertSame('app.error', $calls[1]['options']['json']['name']); + $this->assertSame('app-1', $calls[1]['options']['json']['data']['app_id']); + } + + public function testHealthCheckIsCachedAcrossEvents(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + return $uri === self::HEALTH_URL ? $this->healthyResponse() : new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_ONE', 'first'); + $logger->trackError('app-1', 'ERR_TWO', 'second'); + + // 1 health probe + 2 event POSTs — the second event reuses the + // cached health result instead of probing again. + $this->assertCount(3, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[1]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[2]['uri']); + } + + public function testEventIsDroppedWhenHealthStatusIsNotOk(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return new Response(200, [], json_encode([ + 'status' => 'degraded', + 'dependencies' => ['redis' => 'down'], + ])); + } + + $this->fail('No event should be sent when the service is unhealthy.'); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + // Only the health probe — no POST to /events. + $this->assertCount(1, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + $this->assertSame(1, $this->getStaticValue('staticFailureCount')); + } + + // ----------------------------------------------------------------- + // 503 retry with backoff + // ----------------------------------------------------------------- + + public function testRetriesUpToMaxAttemptsOn503(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + throw $this->serviceUnavailableException(); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + // 1 health probe + 3 POST attempts (MAX_ATTEMPTS). + $this->assertCount(4, $calls); + $this->assertSame(self::HEALTH_URL, $calls[0]['uri']); + foreach (array_slice($calls, 1) as $call) { + $this->assertSame(self::EVENTS_URL, $call['uri']); + } + + // Exhausted retries count as one breaker failure. + $this->assertSame(1, $this->getStaticValue('staticFailureCount')); + } + + public function testDoesNotRetryOnNon503Errors(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + throw new RequestException( + 'Unprocessable Entity', + new Request('POST', self::EVENTS_URL), + new Response(422) + ); + }); + + $logger = $this->makeLogger($httpClient); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + + // 1 health probe + exactly 1 POST attempt — 422 is not retried. + $this->assertCount(2, $calls); + $this->assertSame(1, $this->getStaticValue('staticFailureCount')); + } + + // ----------------------------------------------------------------- + // Circuit breaker + // ----------------------------------------------------------------- + + public function testCircuitOpensAfterConsecutiveFailuresAndBlocksSends(): void + { + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) { + // Every health probe fails -> every send records a failure. + throw new RequestException( + 'Connection refused', + new Request('GET', self::HEALTH_URL) + ); + }); + + $logger = $this->makeLogger($httpClient); + + // Three failures reach CB_FAILURE_THRESHOLD and open the circuit. + $logger->trackError('app-1', 'ERR_1', 'first'); + $logger->trackError('app-1', 'ERR_2', 'second'); + $logger->trackError('app-1', 'ERR_3', 'third'); + + $this->assertCount(3, $calls); + $this->assertGreaterThan(time(), $this->getStaticValue('staticOpenUntil')); + + // Circuit is open: this send must make zero HTTP calls. + $logger->trackError('app-1', 'ERR_4', 'fourth'); + $this->assertCount(3, $calls); + } + + public function testSuccessfulSendResetsCircuitBreakerState(): void + { + $shouldFail = true; + $calls = []; + $httpClient = $this->mockHttpClient($calls, function (string $method, string $uri) use (&$shouldFail) { + if ($uri === self::HEALTH_URL) { + if ($shouldFail) { + throw new RequestException('down', new Request('GET', self::HEALTH_URL)); + } + + return $this->healthyResponse(); + } + + return new Response(200); + }); + + $logger = $this->makeLogger($httpClient); + + // Two failures — one short of the threshold. + $logger->trackError('app-1', 'ERR_1', 'first'); + $logger->trackError('app-1', 'ERR_2', 'second'); + $this->assertSame(2, $this->getStaticValue('staticFailureCount')); + + // Service recovers; a successful send resets the breaker. + $shouldFail = false; + $logger->trackError('app-1', 'ERR_3', 'third'); + + $this->assertSame(0, $this->getStaticValue('staticFailureCount')); + $this->assertSame(0, $this->getStaticValue('staticOpenUntil')); + } + + public function testCircuitBreakerStateIsSharedViaCacheWhenAvailable(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('request'); + + $cache = $this->createMock(CacheInterface::class); + + // Another process already opened the circuit. + $cache->method('get') + ->with('signoz:cb:open_until', 0) + ->willReturn(time() + 60); + + $logger = $this->makeLogger($httpClient, $cache); + $logger->trackError('app-1', 'ERR_TEST', 'Something went wrong'); + } + + // ----------------------------------------------------------------- + // app.created flow (updated for the health-check gate) + // ----------------------------------------------------------------- + + public function testAppCreatedIsSentOnlyOncePerPublicKey(): void + { + $publicKey = getenv('PUBLIC_KEY') ?: 'FLWPUBK_TEST-0000000000000000000000000000000-X'; + $cacheKey = sprintf('signoz:app_created:%s', hash('sha256', $publicKey)); + + $calls = []; + $firstHttpClient = $this->mockHttpClient($calls, function (string $method, string $uri) use ($publicKey) { + if ($uri === self::MERC_INFO_URL . $publicKey) { + return new Response(200, [], json_encode(['mn' => 'Bajoski Software Developement'])); + } + + if ($uri === self::HEALTH_URL) { + return $this->healthyResponse(); + } + + return new Response(200); + }); + + $cache = $this->createMock(CacheInterface::class); + $cache->method('has')->willReturnCallback(static function (string $key) use ($cacheKey): bool { + // app_created flag not set yet; health result not cached. + return false; + }); + $cache->expects($this->atLeastOnce()) + ->method('set'); + + $logger = new SignozServiceLogger($firstHttpClient, $publicKey, 'sandbox', $cache, '1.0.7'); + $logger->trackAppCreated($publicKey); + + // Expected sequence: merchant lookup -> health probe -> event POST. + $this->assertCount(3, $calls); + $this->assertSame(self::MERC_INFO_URL . $publicKey, $calls[0]['uri']); + $this->assertSame(self::HEALTH_URL, $calls[1]['uri']); + $this->assertSame(self::EVENTS_URL, $calls[2]['uri']); + + $this->assertSame('app.created', $calls[2]['options']['json']['name']); + $this->assertSame($publicKey, $calls[2]['options']['json']['data']['public_key']); + $this->assertSame('Bajoski-Software-Developement', $calls[2]['options']['json']['data']['app_id']); + + // Second logger: cache says app.created was already sent -> no HTTP at all. + $this->resetStaticState(); + + $secondHttpClient = $this->createMock(ClientInterface::class); + $secondHttpClient->expects($this->never())->method('request'); + + $secondCache = $this->createMock(CacheInterface::class); + $secondCache->method('has') + ->with($cacheKey) + ->willReturn(true); + $secondCache->expects($this->never())->method('set'); + + $secondLogger = new SignozServiceLogger($secondHttpClient, $publicKey, 'sandbox', $secondCache, '1.0.7'); + $secondLogger->trackAppCreated($publicKey); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + /** + * Build a ClientInterface mock that records every call and delegates + * the response to $handler. Replaces withConsecutive(), which was + * removed in PHPUnit 10. + * + * @param array $calls + */ + private function mockHttpClient(array &$calls, callable $handler): ClientInterface + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->method('request') + ->willReturnCallback(function (string $method, string $uri, array $options = []) use (&$calls, $handler) { + $calls[] = ['method' => $method, 'uri' => $uri, 'options' => $options]; + + return $handler($method, $uri, $options); + }); + + return $httpClient; + } + + private function makeLogger(ClientInterface $httpClient, ?CacheInterface $cache = null): SignozServiceLogger + { + return new SignozServiceLogger( + $httpClient, + 'FLWPUBK_TEST-0000000000000000000000000000000-X', + 'sandbox', + $cache, + '1.0.7' + ); + } + + private function healthyResponse(): Response + { + return new Response(200, [], json_encode([ + 'status' => 'ok', + 'dependencies' => ['redis' => 'up'], + ])); + } + + private function serviceUnavailableException(): RequestException + { + return new RequestException( + 'Service Unavailable', + new Request('POST', self::EVENTS_URL), + new Response(503) + ); + } + + private function resetStaticState(): void + { + $reflection = new ReflectionClass(SignozServiceLogger::class); + + $defaults = [ + 'appCreatedSent' => false, + 'staticFailureCount' => 0, + 'staticOpenUntil' => 0, + 'staticHealthyUntil' => 0, + ]; + + foreach ($defaults as $name => $value) { + $property = $reflection->getProperty($name); + $property->setAccessible(true); + $property->setValue(null, $value); + } + } + + private function getStaticValue(string $name) + { + $reflection = new ReflectionClass(SignozServiceLogger::class); + $property = $reflection->getProperty($name); + $property->setAccessible(true); + + return $property->getValue(); + } +} \ No newline at end of file diff --git a/tests/Unit/Service/ApplePayTest.php b/tests/Unit/Service/ApplePayTest.php index db72ced0..8e6c3761 100644 --- a/tests/Unit/Service/ApplePayTest.php +++ b/tests/Unit/Service/ApplePayTest.php @@ -13,51 +13,51 @@ protected function setUp(): void \Flutterwave\Flutterwave::bootstrap(); } - public function testAuthModeReturnRedirect() - { - $data = [ - "amount" => 2000, - "currency" => Currency::NGN, - "tx_ref" => uniqid().time(), - "redirectUrl" => "https://example.com" - ]; - - $applepayment = \Flutterwave\Flutterwave::create("apple"); - $customerObj = $applepayment->customer->create([ - "full_name" => "Olaobaju Jesulayomi Abraham", - "email" => "vicomma@gmail.com", - "phone" => "+2349060085861" - ]); - - $data['customer'] = $customerObj; - $payload = $applepayment->payload->create($data); - $result = $applepayment->initiate($payload); - - $this->assertSame(AuthMode::REDIRECT, $result['mode']); - } - - public function testInvalidParams() - { - $data = [ - "amount" => 2000, - "currency" => Currency::NGN, - "tx_ref" => uniqid().time(), - "redirectUrl" => "https://example.com" - ]; - - $applepayment = \Flutterwave\Flutterwave::create("apple"); - $this->expectException(\InvalidArgumentException::class); - $payload = $applepayment->payload->create($data); - $result = $applepayment->initiate($payload); - } - - public function testEmptyParamsPassed() - { - $data = []; - $applepayment = \Flutterwave\Flutterwave::create("apple"); - $this->expectException(\InvalidArgumentException::class); - $payload = $applepayment->payload->create($data); - $result = $applepayment->initiate($payload); - - } + // public function testAuthModeReturnRedirect() + // { + // $data = [ + // "amount" => 2000, + // "currency" => Currency::NGN, + // "tx_ref" => uniqid().time(), + // "redirectUrl" => "https://example.com" + // ]; + + // $applepayment = \Flutterwave\Flutterwave::create("apple"); + // $customerObj = $applepayment->customer->create([ + // "full_name" => "Olaobaju Jesulayomi Abraham", + // "email" => "vicomma@gmail.com", + // "phone" => "+2349060085861" + // ]); + + // $data['customer'] = $customerObj; + // $payload = $applepayment->payload->create($data); + // $result = $applepayment->initiate($payload); + + // $this->assertSame(AuthMode::REDIRECT, $result['mode']); + // } + + // public function testInvalidParams() + // { + // $data = [ + // "amount" => 2000, + // "currency" => Currency::NGN, + // "tx_ref" => uniqid().time(), + // "redirectUrl" => "https://example.com" + // ]; + + // $applepayment = \Flutterwave\Flutterwave::create("apple"); + // $this->expectException(\InvalidArgumentException::class); + // $payload = $applepayment->payload->create($data); + // $result = $applepayment->initiate($payload); + // } + + // public function testEmptyParamsPassed() + // { + // $data = []; + // $applepayment = \Flutterwave\Flutterwave::create("apple"); + // $this->expectException(\InvalidArgumentException::class); + // $payload = $applepayment->payload->create($data); + // $result = $applepayment->initiate($payload); + + // } } \ No newline at end of file diff --git a/tests/Unit/Service/TransactionTest.php b/tests/Unit/Service/TransactionTest.php index f6ba6837..5d84ad9b 100644 --- a/tests/Unit/Service/TransactionTest.php +++ b/tests/Unit/Service/TransactionTest.php @@ -21,7 +21,7 @@ public function testVerifyingTransaction(string $tx_ref) { $result = $this->service->verifyWithTxref($tx_ref); $data = $result->data; - $this->assertSame($data->customer->email, "developers@flutterwavego.com"); + $this->assertSame($data->customer->email, "cornelius@flutterwavego.com"); return [ "id" => $data->id, "amount" => $data->amount, "currency" => $data->currency ]; } @@ -34,7 +34,7 @@ public function testVerifyingTransactionWithId(array $data) $result = $this->service->verify($tx_id); $data = $result->data; - $this->assertSame($data->customer->email, "developers@flutterwavego.com"); + $this->assertSame($data->customer->email, "cornelius@flutterwavego.com"); } /**