diff --git a/.github/workflows/change-review.yml b/.github/workflows/change-review.yml new file mode 100644 index 0000000..4b4405c --- /dev/null +++ b/.github/workflows/change-review.yml @@ -0,0 +1,175 @@ +name: Change review + +on: + pull_request: + branches: [main, dev] + push: + branches: [dev] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: change-review-${{ github.ref }} + cancel-in-progress: true + +env: + MODULE_DIR: flutterwavepayment + +jobs: + php-lint: + name: PHP lint (${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # PrestaShop 1.7 through 9 runtimes + php: ['7.2', '7.4', '8.1', '8.4'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: none + + - name: Lint module PHP files + run: | + set -e + find "$MODULE_DIR" -name '*.php' -print0 | xargs -0 -n1 php -l + + module-checks: + name: Module structure + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer + + - name: Validate composer.json + run: composer validate --no-check-publish + + - name: Every directory has an index.php + run: | + missing=$(find "$MODULE_DIR" -type d | while read -r d; do [ -f "$d/index.php" ] || echo "$d"; done) + if [ -n "$missing" ]; then + echo "::error::Directories without index.php:" + echo "$missing" + exit 1 + fi + + - name: Every PHP file guards against direct access + run: | + missing=$(find "$MODULE_DIR" -name '*.php' ! -name index.php | while read -r f; do grep -q "_PS_VERSION_" "$f" || echo "$f"; done) + if [ -n "$missing" ]; then + echo "::error::PHP files without an _PS_VERSION_ guard:" + echo "$missing" + exit 1 + fi + + - name: Module and config.xml versions match + id: version + run: | + php_version=$(sed -n "s/.*\$this->version = '\([^']*\)'.*/\1/p" "$MODULE_DIR/$MODULE_DIR.php") + xml_version=$(sed -n 's/.*<\/version>.*/\1/p' "$MODULE_DIR/config.xml") + echo "Module: $php_version, config.xml: $xml_version" + if [ -z "$php_version" ] || [ "$php_version" != "$xml_version" ]; then + echo "::error::Version in $MODULE_DIR.php ($php_version) does not match config.xml ($xml_version)" + exit 1 + fi + echo "current=$php_version" >> "$GITHUB_OUTPUT" + + - name: Version bumps ship an upgrade script + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CURRENT: ${{ steps.version.outputs.current }} + run: | + base=$(git show "$BASE_SHA:$MODULE_DIR/$MODULE_DIR.php" 2>/dev/null | sed -n "s/.*\$this->version = '\([^']*\)'.*/\1/p") + echo "Base: ${base:-none}, head: $CURRENT" + if [ -n "$base" ] && [ "$base" != "$CURRENT" ] && [ ! -f "$MODULE_DIR/upgrade/upgrade-$CURRENT.php" ]; then + echo "::warning::Version changed from $base to $CURRENT without $MODULE_DIR/upgrade/upgrade-$CURRENT.php. Add one if the database or hooks changed." + fi + + - name: Database schema changes ship an upgrade script + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + schema_changed=$(git diff "$BASE_SHA"...HEAD -- "$MODULE_DIR/$MODULE_DIR.php" | grep -E '^[+-].*(CREATE TABLE|KEY `|` (INT|VARCHAR|DECIMAL|TINYINT|DATETIME))' || true) + upgrade_added=$(git diff --name-only --diff-filter=AM "$BASE_SHA"...HEAD -- "$MODULE_DIR/upgrade/" || true) + if [ -n "$schema_changed" ] && [ -z "$upgrade_added" ]; then + echo "::error::The install schema changed but no upgrade script was added, so existing shops will not get the change:" + echo "$schema_changed" + exit 1 + fi + + tests: + name: Pest tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + # Pest 4 requires PHP 8.3+ + php-version: '8.4' + coverage: none + tools: composer + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache/files + key: composer-${{ hashFiles('composer.lock') }} + restore-keys: composer- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run tests + run: vendor/bin/pest --ci + + package: + name: Build module zip + runs-on: ubuntu-latest + needs: [php-lint, module-checks, tests] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create module zip + run: | + set -e + mkdir -p dist/package-root + cp -R "$MODULE_DIR" "dist/package-root/$MODULE_DIR" + cd dist/package-root + zip -r "../$MODULE_DIR.zip" "$MODULE_DIR" + cd .. + unzip -l "$MODULE_DIR.zip" | grep -q "$MODULE_DIR/$MODULE_DIR.php" + ls -lh "$MODULE_DIR.zip" + + - name: Upload zip for manual testing + uses: actions/upload-artifact@v4 + with: + name: ${{ env.MODULE_DIR }}-${{ github.sha }} + path: dist/${{ env.MODULE_DIR }}.zip + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f0257b..c51f436 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Inject SigNoz API key + env: + SIGNOZ_API_KEY: ${{ secrets.SIGNOZ_API_KEY }} + run: | + sed -i "s|%%SIGNOZ_API_KEY%%|${SIGNOZ_API_KEY}|g" "$MODULE_DIR/classes/FlutterwaveSignozLogger.php" + - name: Create release zip run: | set -e diff --git a/CHANGELOG.md b/CHANGELOG.md index aea30af..6853bdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to the Flutterwave PrestaShop Payment Module will be documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - Unreleased + +### Added +- SigNoz monitoring (`app.created`, `request.sent`, `app.transaction`, `app.error`) for integration events and errors, ported from the WooCommerce plugin. Events are sent after the response is flushed, behind a health check and circuit breaker, so they never slow down or break checkout. + ## [1.0.0] - 2026-05-08 ### Added diff --git a/flutterwavepayment/classes/FlutterwaveApiClient.php b/flutterwavepayment/classes/FlutterwaveApiClient.php index ff6d27a..bccfe88 100644 --- a/flutterwavepayment/classes/FlutterwaveApiClient.php +++ b/flutterwavepayment/classes/FlutterwaveApiClient.php @@ -124,22 +124,11 @@ public function refundTransaction($transactionId, $amount = null) */ private function getRequest($endpoint, $headers = []) { - $url = $this->apiUrl . $endpoint; - $headers = array_merge($headers, [ 'Accept: application/json', ]); - $ch = curl_init($url); - - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 60, - CURLOPT_CONNECTTIMEOUT => 10, - ]); - - return $this->executeRequest($ch); + return $this->executeRequest('GET', $this->apiUrl . $endpoint, $headers); } /** @@ -153,7 +142,6 @@ private function getRequest($endpoint, $headers = []) */ private function postRequest($endpoint, array $data, $headers = []) { - $url = $this->apiUrl . $endpoint; $payload = json_encode($data); if ($payload === false) { @@ -166,34 +154,22 @@ private function postRequest($endpoint, array $data, $headers = []) 'User-Agent: Flutterwave Payment PrestaShop Module' ]); - $ch = curl_init($url); - - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 60, - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $payload, - ]); - - return $this->executeRequest($ch); + return $this->executeRequest('POST', $this->apiUrl . $endpoint, $headers, $payload); } /** - * Execute the prepared cURL request and handle response/errors + * Send the request and handle response/errors * - * @param resource $ch + * @param string $method + * @param string $url + * @param array $headers + * @param string|null $body * @return array * @throws Exception */ - private function executeRequest($ch) + private function executeRequest($method, $url, array $headers, $body = null) { - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - - curl_close($ch); + list($response, $httpCode, $error) = $this->sendHttpRequest($method, $url, $headers, $body); if ($error) { throw new \Exception('cURL Error: ' . $error); @@ -228,27 +204,58 @@ private function executeRequest($ch) } /** - * Verify webhook signature + * Perform the HTTP request * - * @param string $payload Webhook payload (raw POST body) - * @param string $signature Webhook signature from header - * @param string $webhookSecret Webhook secret from Flutterwave Dashboard - * @return bool + * @param string $method + * @param string $url + * @param array $headers + * @param string|null $body + * @return array [response body, HTTP status code, cURL error] */ - public static function verifyWebhookSignature($payload, $signature, $webhookSecret) + protected function sendHttpRequest($method, $url, array $headers, $body = null) { - if (empty($webhookSecret) || empty($signature)) { - return false; - } + $ch = curl_init($url); + + $options = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 60, + CURLOPT_CONNECTTIMEOUT => 10, + ]; - // Remove any prefix if present (e.g., 'sha256=') - $cleanSignature = $signature; - if (strpos($signature, '=') !== false) { - list($algorithm, $cleanSignature) = explode('=', $signature, 2); + if ($method === 'POST') { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = $body; } - $computedSignature = hash_hmac('sha256', $payload, $webhookSecret); + curl_setopt_array($ch, $options); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + curl_close($ch); + + return [$response, $httpCode, $error]; + } + + /** + * Verify the verif-hash webhook header + * + * Flutterwave sends the secret hash configured in the dashboard as-is; it + * is not an HMAC of the body. It only authenticates the sender, so payment + * details must still be requeried from the API. + * + * @param string $header verif-hash header value + * @param string $webhookSecret Secret hash from Flutterwave Dashboard + * @return bool + */ + public static function verifyWebhookHash($header, $webhookSecret) + { + if (empty($webhookSecret) || empty($header)) { + return false; + } - return hash_equals($computedSignature, $cleanSignature); + return hash_equals((string) $webhookSecret, (string) $header); } } diff --git a/flutterwavepayment/classes/FlutterwaveRefundService.php b/flutterwavepayment/classes/FlutterwaveRefundService.php index 7f48fb3..0d9233e 100644 --- a/flutterwavepayment/classes/FlutterwaveRefundService.php +++ b/flutterwavepayment/classes/FlutterwaveRefundService.php @@ -6,6 +6,11 @@ * @copyright 2024 Flutterwave Payment * @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0) */ + +if (!defined('_PS_VERSION_')) { + exit; +} + require_once dirname(__FILE__) . '/FlutterwaveApiClient.php'; use FlutterwavePayment\classes\FlutterwaveApiClient; diff --git a/flutterwavepayment/classes/FlutterwaveSignozLogger.php b/flutterwavepayment/classes/FlutterwaveSignozLogger.php new file mode 100644 index 0000000..4025725 --- /dev/null +++ b/flutterwavepayment/classes/FlutterwaveSignozLogger.php @@ -0,0 +1,1141 @@ + In-process trace context registry (warm cache in front of the state table). */ + private $traceContextsByReference = []; + + /** @var array|null Default trace context applied when none is registered for a reference. */ + private $defaultTraceContext = null; + + /** @var array Events waiting for the shutdown flush. */ + private $pendingEvents = []; + + /** @var bool */ + private $shutdownRegistered = false; + + /** @var float|null microtime(true) deadline while a flush is running. */ + private $flushDeadline = null; + + protected function __construct() + { + } + + /** + * @return static + */ + public static function instance() + { + if (null === self::$instance) { + self::$instance = new static(); + } + + return self::$instance; + } + + /** + * Swap the singleton (tests only). + * + * @param FlutterwaveSignozLogger|null $instance + */ + public static function setInstance($instance) + { + self::$instance = $instance; + } + + // --- Environment / identity ----------------------------------------- + + /** + * @return string "production" or "sandbox" + */ + public function getCurrentEnvironment() + { + return (int) $this->getConfig('FLUTTERWAVE_LIVE_MODE') ? 'production' : 'sandbox'; + } + + /** + * Prefers the backend-generated app_id (from app.created), then the + * configured public key, then an anonymous marker. + * + * @return string + */ + public function getAppId() + { + if ('' !== $this->appId) { + return $this->appId; + } + + $stored = (string) $this->getConfig(self::CONFIG_APP_ID); + if ('' !== $stored) { + $this->appId = $this->normalizeAppId($stored); + return $this->appId; + } + + $publicKey = (string) $this->getConfig('FLUTTERWAVE_PUBLIC_KEY'); + if ('' !== $publicKey) { + return $this->normalizeAppId($publicKey); + } + + return 'unknown'; + } + + /** + * Whether app.created has succeeded for the given module version. + * + * @param string $version + * @return bool + */ + public function isAppRegistered($version) + { + return (bool) $this->getConfig(self::CONFIG_APP_REGISTERED) + && '' !== (string) $this->getConfig(self::CONFIG_APP_ID) + && (string) $this->getConfig(self::CONFIG_REGISTERED_VERSION) === (string) $version; + } + + /** + * Forget the stored app_id when the merchant switches to a different + * public key, so the next registration creates an app for the new key. + * + * @param string $publicKey + */ + public function resetRegistrationIfKeyChanged($publicKey) + { + $registeredKey = (string) $this->getConfig(self::CONFIG_REGISTERED_KEY); + + if ('' === $registeredKey || $registeredKey === (string) $publicKey) { + return; + } + + $this->appId = ''; + $this->updateConfig(self::CONFIG_APP_ID, ''); + $this->updateConfig(self::CONFIG_APP_REGISTERED, 0); + $this->updateConfig(self::CONFIG_REGISTERED_KEY, ''); + } + + /** + * Register the app if it is missing or was registered on an older + * module version. Blocks on the network, so only call it from the back office. + * + * @param string $publicKey + * @param string $version Running module version. + * @return string|null app_id, or null when the service is unavailable. + */ + public function registerApp($publicKey, $version) + { + try { + if ('' === (string) $publicKey) { + return null; + } + + $this->resetRegistrationIfKeyChanged($publicKey); + $appId = $this->trackAppCreated($publicKey); + + if (null === $appId) { + return null; // Service unavailable / circuit open — retried later. + } + + $this->updateConfig(self::CONFIG_APP_REGISTERED, 1); + $this->updateConfig(self::CONFIG_REGISTERED_KEY, (string) $publicKey); + $this->updateConfig(self::CONFIG_REGISTERED_VERSION, (string) $version); + + return $appId; + } catch (\Throwable $e) { + // Observability must never break the shop. + unset($e); + } + + return null; + } + + /** + * Back-office catch-all for merchants who upgrade without re-saving the + * settings. Cheap when already registered; otherwise defers the network + * call until after the admin page has been sent, at most once per + * REGISTER_ATTEMPT_TTL. + * + * @param string $version Running module version. + */ + public function maybeRegisterInBackground($version) + { + try { + if ($this->isAppRegistered($version)) { + return; + } + + $publicKey = (string) $this->getConfig('FLUTTERWAVE_PUBLIC_KEY'); + if ('' === $publicKey) { + return; // Merchant hasn't configured keys yet — nothing to register. + } + + if ($this->stateGet(self::REGISTER_ATTEMPT_KEY)) { + return; // Tried recently; avoid probing on every admin page. + } + + $this->stateSet(self::REGISTER_ATTEMPT_KEY, true, self::REGISTER_ATTEMPT_TTL); + + $this->deferToShutdown(function () use ($publicKey, $version) { + $this->registerApp($publicKey, $version); + }); + } catch (\Throwable $e) { + unset($e); + } + } + + // --- Trace context ---------------------------------------------------- + + /** + * @param array|null $traceContext W3C-style trace context or null to clear. + */ + public function setDefaultTraceContext($traceContext) + { + $this->defaultTraceContext = $traceContext; + } + + /** + * @return array|null + */ + public function getDefaultTraceContext() + { + return $this->defaultTraceContext; + } + + /** + * Register (or clear) the trace context for a transaction reference. + * Persisted in the state table so it survives across requests + * (checkout -> return URL / webhook). + * + * @param string $reference + * @param array|null $traceContext + */ + public function setTraceContextForReference($reference, $traceContext) + { + try { + $key = $this->normalizeReference($reference); + + if ('' === $key) { + return; + } + + if (null === $traceContext) { + unset($this->traceContextsByReference[$key]); + $this->stateDelete($this->traceContextKey($key)); + return; + } + + $this->traceContextsByReference[$key] = $traceContext; + $this->stateSet($this->traceContextKey($key), $traceContext, self::TRACE_CTX_TTL); + } catch (\Throwable $e) { + unset($e); + } + } + + /** + * @param string $reference + * @return array|null + */ + public function getTraceContextForReference($reference) + { + try { + $key = $this->normalizeReference($reference); + + if ('' === $key) { + return null; + } + + if (isset($this->traceContextsByReference[$key])) { + return $this->traceContextsByReference[$key]; + } + + $stored = $this->stateGet($this->traceContextKey($key)); + + if (is_array($stored)) { + $this->traceContextsByReference[$key] = $stored; + return $stored; + } + } catch (\Throwable $e) { + unset($e); + } + + return null; + } + + /** + * Resolve the trace context for an event: explicit > default > by-reference. + * Creates a root span when nothing is known about the reference, otherwise a + * child span under the previous one so the trace stays linked by tx_ref. + * + * @param array|null $explicit + * @param string $reference + * @return array + */ + private function resolveTraceContext($explicit, $reference) + { + $parent = $explicit; + if (null === $parent) { + $parent = $this->defaultTraceContext; + } + if (null === $parent) { + $parent = $this->getTraceContextForReference($reference); + } + + $context = $this->buildTraceContext($parent); + $this->setTraceContextForReference($reference, $context); + + return $context; + } + + /** + * @param array|null $parent + * @return array + */ + private function buildTraceContext($parent = null) + { + $context = [ + 'trace_id' => $this->randomHex(16), + 'span_id' => $this->randomHex(8), + ]; + + if (null !== $parent) { + $traceId = $this->extractTraceId($parent); + if (null !== $traceId) { + $context['trace_id'] = $traceId; + } + + $spanId = $this->extractSpanId($parent); + if (null !== $spanId && '' !== $spanId) { + $context['parent_span_id'] = $spanId; + } + } + + return $context; + } + + /** + * @param int $bytes + * @return string + */ + private function randomHex($bytes) + { + try { + return bin2hex(random_bytes($bytes)); + } catch (\Throwable $e) { + return substr(md5(uniqid('', true) . microtime(true)), 0, $bytes * 2); + } + } + + /** + * @param array $context + * @return string|null + */ + private function extractTraceId(array $context) + { + if (isset($context['trace_id']) && is_string($context['trace_id']) && '' !== trim($context['trace_id'])) { + return $context['trace_id']; + } + + if (isset($context['traceparent']) && is_string($context['traceparent'])) { + $parts = explode('-', $context['traceparent']); + return isset($parts[1]) ? $parts[1] : null; + } + + return null; + } + + /** + * @param array $context + * @return string|null + */ + private function extractSpanId(array $context) + { + if (isset($context['span_id']) && is_string($context['span_id']) && '' !== trim($context['span_id'])) { + return $context['span_id']; + } + + if (isset($context['traceparent']) && is_string($context['traceparent'])) { + $parts = explode('-', $context['traceparent']); + return isset($parts[2]) ? $parts[2] : null; + } + + return null; + } + + /** + * @param string $normalizedReference + * @return string + */ + private function traceContextKey($normalizedReference) + { + return self::TRACE_CTX_KEY_PREFIX . md5($normalizedReference); + } + + // --- Events ------------------------------------------------------------- + + /** + * Fire the `app.created` event and persist the backend-generated app_id. + * Idempotent: skips the network call when an app_id is already stored. + * + * Intentionally blocking: the response body carries the app_id, and this + * only runs from the back office, never during checkout. + * + * @param string $publicKey + * @return string|null + */ + public function trackAppCreated($publicKey) + { + try { + if ('' === (string) $publicKey) { + return null; + } + + $stored = (string) $this->getConfig(self::CONFIG_APP_ID); + if ('' !== $stored) { + $this->appId = $this->normalizeAppId($stored); + return $this->appId; + } + + $response = $this->sendNow( + 'app.created', + [ + 'client_id' => null, + 'public_key' => (string) $publicKey, + 'library' => self::LIBRARY, + 'library_version' => $this->libraryVersion(), + ], + $this->timestamp() + ); + + if (!is_array($response) || empty($response['app_id'])) { + return null; + } + + $this->appId = $this->normalizeAppId((string) $response['app_id']); + $this->updateConfig(self::CONFIG_APP_ID, $this->appId); + + return $this->appId; + } catch (\Throwable $e) { + unset($e); + } + + return null; + } + + /** + * Fire the `request.sent` event when a payment request is initiated. + * + * @param string $method HTTP method (e.g. "POST"). + * @param string $reference Transaction reference (tx_ref). + * @param string $path Request path (e.g. "/payments"). + * @param array|null $traceContext Optional trace context override. + */ + public function trackRequestSent($method, $reference, $path, $traceContext = null) + { + try { + $safeReference = $this->normalizeReference($reference); + + $payload = [ + 'app_id' => $this->getAppId(), + 'environment' => $this->getCurrentEnvironment(), + 'api_version' => 'v3', + 'library' => self::LIBRARY, + 'library_version' => $this->libraryVersion(), + 'method' => (string) $method, + 'path' => (string) $path, + 'reference' => $safeReference, + ]; + + $cacheKey = self::REQUEST_SENT_KEY_PREFIX . md5($safeReference); + + if ($this->stateGet($cacheKey)) { + return; // Already sent recently. + } + + $this->stateSet($cacheKey, true, self::REQUEST_SENT_TTL); + + $payload['trace_context'] = $this->resolveTraceContext($traceContext, $safeReference); + + $this->queueEvent('request.sent', $payload); + } catch (\Throwable $e) { + unset($e); + } + } + + /** + * Fire the `app.transaction` event after a successful payment. + * + * @param string $reference + * @param string $currency ISO 4217 currency code. + * @param float $amount + * @param string $method Payment method (e.g. "card"). + * @param float $fee + * @param array|null $traceContext + */ + public function trackTransaction($reference, $currency, $amount, $method, $fee, $traceContext = null) + { + try { + $safeReference = $this->normalizeReference($reference); + + $payload = [ + 'app_id' => $this->getAppId(), + 'reference' => $safeReference, + 'library' => self::LIBRARY, + 'currency' => (string) $currency, + 'amount' => (float) $amount, + 'fee' => (float) $fee, + 'method' => (string) $method, + ]; + + $payload['trace_context'] = $this->resolveTraceContext($traceContext, $safeReference); + + $this->queueEvent('app.transaction', $payload); + } catch (\Throwable $e) { + unset($e); + } + } + + /** + * Fire the `app.error` event. Errors without a reference are throttled to + * one per error code every ERROR_THROTTLE_TTL seconds. + * + * @param string $errorCode Short machine-readable error code. + * @param string $errorMessage Human-readable description. + * @param string $reference Optional transaction reference for trace correlation. + * @param array|null $traceContext + * @param string|null $stackTrace Optional stack trace (truncated before send). + */ + public function trackError($errorCode, $errorMessage, $reference = '', $traceContext = null, $stackTrace = null) + { + try { + $safeReference = $this->normalizeReference((string) $reference); + + if ('' === $safeReference) { + $throttleKey = self::ERROR_THROTTLE_KEY_PREFIX . md5((string) $errorCode); + + if ($this->stateGet($throttleKey)) { + return; + } + + $this->stateSet($throttleKey, true, self::ERROR_THROTTLE_TTL); + } + + $payload = [ + 'app_id' => $this->getAppId(), + 'library' => self::LIBRARY, + 'library_version' => $this->libraryVersion(), + 'error_code' => (string) $errorCode, + 'error_message' => $this->truncate((string) $errorMessage, self::ERROR_MESSAGE_MAX_LENGTH), + ]; + + if (null !== $stackTrace && '' !== $stackTrace) { + $payload['error_stacktrace'] = $this->truncate((string) $stackTrace, self::ERROR_STACKTRACE_MAX_LENGTH); + } + + if ('' !== $safeReference) { + $payload['reference'] = $safeReference; + } + + $payload['trace_context'] = $this->resolveTraceContext($traceContext, $safeReference); + + $this->queueEvent('app.error', $payload); + } catch (\Throwable $e) { + unset($e); + } + } + + // --- Deferred dispatch -------------------------------------------------- + + /** + * Buffer an event for the shutdown flush. The timestamp is captured now, + * not at send time, so events reflect when they actually happened. + * + * @param string $eventName + * @param array $data + */ + private function queueEvent($eventName, array $data) + { + $this->pendingEvents[] = [$eventName, $data, $this->timestamp()]; + $this->deferToShutdown(null); + } + + /** + * @return array + */ + public function getPendingEvents() + { + return $this->pendingEvents; + } + + /** + * Send every buffered event. Registered as a shutdown function; never throws. + */ + public function flush() + { + $events = $this->pendingEvents; + $this->pendingEvents = []; + $this->flushDeadline = $this->now() + self::FLUSH_BUDGET_MS / 1000; + + foreach ($events as $event) { + if (null === $this->remainingMs(self::EVENT_TIMEOUT_MS)) { + break; // Budget spent — drop the rest rather than hold the worker. + } + + try { + $this->sendNow($event[0], $event[1], $event[2]); + } catch (\Throwable $e) { + unset($e); + } + } + + $this->flushDeadline = null; + + try { + $this->purgeExpiredState(); + } catch (\Throwable $e) { + unset($e); + } + } + + /** + * Register the shutdown flush once per request, optionally with extra work. + * Controllers end with exit/die and Tools::redirect, which still run + * shutdown functions. + * + * @param callable|null $task + */ + private function deferToShutdown($task) + { + if (null !== $task) { + register_shutdown_function(function () use ($task) { + $this->releaseClient(); + try { + $task(); + } catch (\Throwable $e) { + unset($e); + } + }); + } + + if ($this->shutdownRegistered) { + return; + } + + $this->shutdownRegistered = true; + register_shutdown_function(function () { + if (empty($this->pendingEvents)) { + return; + } + $this->releaseClient(); + $this->flush(); + }); + } + + /** + * Finish the HTTP response so the shopper never waits on SigNoz. + */ + protected function releaseClient() + { + if (PHP_SAPI === 'cli') { + return; + } + + ignore_user_abort(true); + + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } elseif (function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + } + } + + // --- Transport: health gate + circuit breaker + retry ------------------- + + /** + * @param string $eventName + * @param array $data + * @param string $timestamp + * @return array|null Decoded JSON response body, or null when the event was + * dropped, failed, or returned a non-JSON body. + */ + private function sendNow($eventName, array $data, $timestamp) + { + try { + // 1. Circuit breaker gate: if open, drop the event immediately. + if ($this->isCircuitOpen()) { + return null; + } + + // 2. Health gate (cached for HEALTH_CACHE_TTL). When the circuit has + // just left cooldown this is the half-open probe. + $healthy = $this->isServiceHealthy(); + if (null === $healthy) { + return null; // Ran out of flush budget; no verdict on the service. + } + if (!$healthy) { + $this->recordFailure(); + return null; + } + + return $this->sendWithRetry($eventName, $data, $timestamp); + } catch (\Throwable $e) { + unset($e); + } + + return null; + } + + /** + * POST the event. Blocking sends (app.created) retry 503s with jittered + * backoff; sends during a flush get one attempt within the flush budget. + * + * @param string $eventName + * @param array $data + * @param string $timestamp + * @return array|null + */ + private function sendWithRetry($eventName, array $data, $timestamp) + { + $body = json_encode([ + 'name' => $eventName, + 'data' => $data, + 'timestamp' => $timestamp, + ]); + + if (false === $body) { + return null; + } + + $flushing = null !== $this->flushDeadline; + $maxAttempts = $flushing ? 1 : self::MAX_ATTEMPTS; + $defaultTimeoutMs = $flushing ? self::EVENT_TIMEOUT_MS : self::BLOCKING_TIMEOUT_MS; + + for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { + $timeoutMs = $this->remainingMs($defaultTimeoutMs); + if (null === $timeoutMs) { + return null; // Out of budget; not the service's fault. + } + + list($response, $statusCode, $error) = $this->sendHttpRequest( + 'POST', + self::BASE_URL . '/events', + ['Content-Type: application/json', 'Accept: application/json'], + $body, + $timeoutMs + ); + + // Network/transport error (including a slow response hitting the + // full timeout): count as a failure, do not retry. A timeout we + // shortened to fit the budget says nothing about the service. + if ('' !== (string) $error) { + if ($timeoutMs >= $defaultTimeoutMs) { + $this->recordFailure(); + } + return null; + } + + $statusCode = (int) $statusCode; + + if ($statusCode >= 200 && $statusCode < 300) { + $this->recordSuccess(); + $decoded = json_decode((string) $response, true); + return is_array($decoded) ? $decoded : null; + } + + // Only 503 (service temporarily unavailable) is retried. + if (503 === $statusCode && $attempt < $maxAttempts) { + $this->backoffSleep($attempt); + continue; + } + + $this->recordFailure(); + return null; + } + + $this->recordFailure(); + return null; + } + + /** + * Time left for one HTTP call, capped at $defaultMs. Outside a flush there + * is no budget and the default applies. + * + * @param int $defaultMs + * @return int|null Milliseconds, or null when the flush budget is spent. + */ + private function remainingMs($defaultMs) + { + if (null === $this->flushDeadline) { + return $defaultMs; + } + + $left = (int) floor(($this->flushDeadline - $this->now()) * 1000); + + // Too little time left for a meaningful request. + return $left < 100 ? null : min($defaultMs, $left); + } + + /** + * @return float + */ + protected function now() + { + return microtime(true); + } + + /** + * Exponential backoff with full jitter. + * + * @param int $attempt 1-indexed attempt number. + */ + protected function backoffSleep($attempt) + { + $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 pass is cached. + * + * @return bool|null null when the flush budget left no room for a fair check. + */ + private function isServiceHealthy() + { + if ($this->stateGet(self::HEALTH_OK_KEY)) { + return true; + } + + try { + $timeoutMs = $this->remainingMs(self::HEALTH_TIMEOUT_MS); + if (null === $timeoutMs) { + return null; + } + + list($response, $statusCode, $error) = $this->sendHttpRequest( + 'GET', + self::BASE_URL . self::HEALTH_PATH, + ['Accept: application/json'], + null, + $timeoutMs + ); + + if ('' !== (string) $error && $timeoutMs < self::HEALTH_TIMEOUT_MS) { + return null; + } + + if ('' !== (string) $error || 200 !== (int) $statusCode) { + return false; + } + + $result = json_decode((string) $response, true); + $healthy = is_array($result) && isset($result['status']) && 'ok' === $result['status']; + + if ($healthy) { + $this->stateSet(self::HEALTH_OK_KEY, true, self::HEALTH_CACHE_TTL); + } + + return $healthy; + } catch (\Throwable $e) { + return false; + } + } + + /** + * @return bool + */ + private function isCircuitOpen() + { + return (bool) $this->stateGet(self::CB_OPEN_KEY); + } + + private function recordSuccess() + { + $this->stateDelete(self::CB_FAILURES_KEY); + $this->stateDelete(self::CB_OPEN_KEY); + } + + private function recordFailure() + { + $failures = (int) $this->stateGet(self::CB_FAILURES_KEY) + 1; + $this->stateSet(self::CB_FAILURES_KEY, $failures, self::CB_OPEN_TTL * 2); + + if ($failures >= self::CB_FAILURE_THRESHOLD) { + $this->openCircuit(); + } + } + + /** + * Open the circuit and drop the cached health status so the first attempt + * after cooldown re-probes /health/ready (half-open). + */ + private function openCircuit() + { + $this->stateSet(self::CB_OPEN_KEY, true, self::CB_OPEN_TTL); + $this->stateDelete(self::CB_FAILURES_KEY); + $this->stateDelete(self::HEALTH_OK_KEY); + } + + // --- Helpers -------------------------------------------------------------- + + /** + * @param string $appId + * @return string + */ + private function normalizeAppId($appId) + { + $normalized = preg_replace('/\s+/', '-', trim($appId)); + return null !== $normalized ? $normalized : $appId; + } + + /** + * Restrict references to [A-Za-z0-9_-] so state keys and payloads are stable. + * + * @param string $reference + * @return string + */ + private function normalizeReference($reference) + { + $normalized = preg_replace('/[^A-Za-z0-9_-]+/', '-', trim((string) $reference)); + + if (null === $normalized) { + return (string) $reference; + } + + return trim($normalized, '-'); + } + + /** + * @param string $value + * @param int $maxLength + * @return string + */ + private function truncate($value, $maxLength) + { + if (function_exists('mb_strlen')) { + return mb_strlen($value) <= $maxLength ? $value : mb_substr($value, 0, $maxLength); + } + + return strlen($value) <= $maxLength ? $value : substr($value, 0, $maxLength); + } + + /** + * @return string + */ + private function timestamp() + { + return gmdate('Y-m-d\TH:i:s.000\Z'); + } + + /** + * @return string + */ + protected function libraryVersion() + { + if (class_exists('Module', false)) { + $module = \Module::getInstanceByName('flutterwavepayment'); + if ($module && !empty($module->version)) { + return (string) $module->version; + } + } + + return self::LIBRARY_VERSION_FALLBACK; + } + + // --- Platform adapters (overridden in tests) ------------------------------ + + /** + * @param string $key + * @return mixed + */ + protected function getConfig($key) + { + return \Configuration::get($key); + } + + /** + * @param string $key + * @param mixed $value + */ + protected function updateConfig($key, $value) + { + \Configuration::updateValue($key, $value); + } + + /** + * Read a value from the TTL state table. + * + * @param string $name + * @return mixed|null + */ + protected function stateGet($name) + { + $row = \Db::getInstance()->getRow( + 'SELECT `value`, `expires_at` FROM `' . _DB_PREFIX_ . self::STATE_TABLE . '` + WHERE `name` = "' . pSQL($name) . '"', + false + ); + + if (!$row || (int) $row['expires_at'] < time()) { + return null; + } + + return json_decode($row['value'], true); + } + + /** + * @param string $name + * @param mixed $value + * @param int $ttl Seconds. + */ + protected function stateSet($name, $value, $ttl) + { + \Db::getInstance()->execute( + 'REPLACE INTO `' . _DB_PREFIX_ . self::STATE_TABLE . '` (`name`, `value`, `expires_at`) + VALUES ("' . pSQL($name) . '", "' . pSQL(json_encode($value), true) . '", ' . (int) (time() + $ttl) . ')' + ); + } + + /** + * @param string $name + */ + protected function stateDelete($name) + { + \Db::getInstance()->delete(self::STATE_TABLE, '`name` = "' . pSQL($name) . '"'); + } + + protected function purgeExpiredState() + { + \Db::getInstance()->delete(self::STATE_TABLE, '`expires_at` < ' . (int) time()); + } + + /** + * @param string $method + * @param string $url + * @param array $headers + * @param string|null $body + * @param int $timeoutMs Total and connect timeout in milliseconds. + * @return array [response body, HTTP status code, transport error] + */ + protected function sendHttpRequest($method, $url, array $headers, $body, $timeoutMs) + { + $ch = curl_init($url); + + $options = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT_MS => $timeoutMs, + CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs, + // Sub-second timeouts need this, or libcurl's signal-based + // resolver ignores them. + CURLOPT_NOSIGNAL => true, + ]; + + if ('POST' === $method) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = $body; + } + + curl_setopt_array($ch, $options); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + curl_close($ch); + + return [$response, $httpCode, $error]; + } +} diff --git a/flutterwavepayment/classes/FlutterwaveTransactionVerifier.php b/flutterwavepayment/classes/FlutterwaveTransactionVerifier.php new file mode 100644 index 0000000..556a123 --- /dev/null +++ b/flutterwavepayment/classes/FlutterwaveTransactionVerifier.php @@ -0,0 +1,111 @@ + 0) { + return (int) $matches[1]; + } + + return null; + } + + /** + * @param string $reference + * @param int $cartId + * @return bool + */ + public static function referenceBelongsToCart($reference, $cartId) + { + return (int) $cartId > 0 && self::cartIdFromReference($reference) === (int) $cartId; + } + + public static function isSuccessful($status) + { + return in_array($status, ['successful', 'success', 'completed'], true); + } + + public static function isPending($status) + { + return in_array($status, ['pending', 'processing'], true); + } + + public static function isFailed($status) + { + return in_array($status, ['failed', 'declined'], true); + } + + /** + * Ensure the verified transaction is the one that was requested + * + * @param array $transactionData "data" object from the verify API + * @param string $reference Reference that was requeried + * @throws \Exception + */ + public static function assertReference(array $transactionData, $reference) + { + if (empty($reference) + || !isset($transactionData['tx_ref']) + || (string) $transactionData['tx_ref'] !== (string) $reference + ) { + throw new \Exception('Payment reference mismatch'); + } + } + + /** + * Ensure a successful transaction paid the expected amount and currency + * + * @param array $transactionData "data" object from the verify API + * @param float $expectedAmount Cart or order total + * @param string $expectedCurrency ISO currency code of the cart or order + * @return string Flutterwave transaction ID + * @throws \Exception + */ + public static function assertPaymentMatches(array $transactionData, $expectedAmount, $expectedCurrency) + { + if (empty($transactionData['id'])) { + throw new \Exception('Transaction ID missing from verification response'); + } + + if (!isset($transactionData['amount']) + || !is_numeric($transactionData['amount']) + || abs((float) $transactionData['amount'] - (float) $expectedAmount) > self::AMOUNT_TOLERANCE + ) { + throw new \Exception('Payment amount mismatch'); + } + + if (!isset($transactionData['currency']) || (string) $transactionData['currency'] !== (string) $expectedCurrency) { + throw new \Exception('Payment currency mismatch'); + } + + return (string) $transactionData['id']; + } +} diff --git a/flutterwavepayment/config.xml b/flutterwavepayment/config.xml index a025845..d15d9c1 100644 --- a/flutterwavepayment/config.xml +++ b/flutterwavepayment/config.xml @@ -2,7 +2,7 @@ flutterwavepayment - + diff --git a/flutterwavepayment/controllers/admin/AdminFlutterwaveRefundController.php b/flutterwavepayment/controllers/admin/AdminFlutterwaveRefundController.php index 3066a65..f673188 100644 --- a/flutterwavepayment/controllers/admin/AdminFlutterwaveRefundController.php +++ b/flutterwavepayment/controllers/admin/AdminFlutterwaveRefundController.php @@ -1,5 +1,9 @@ getTransactionByOrderId($orderId); + + \FlutterwavePayment\classes\FlutterwaveSignozLogger::instance()->trackError( + 'REFUND_FAILED', + $e->getMessage(), + $transaction ? (string) $transaction['flutterwave_reference'] : '' + ); + $this->errors[] = $e->getMessage(); } diff --git a/flutterwavepayment/controllers/admin/index.php b/flutterwavepayment/controllers/admin/index.php new file mode 100644 index 0000000..81b7fd1 --- /dev/null +++ b/flutterwavepayment/controllers/admin/index.php @@ -0,0 +1,18 @@ +module->getPublicKey() ); + FlutterwaveSignozLogger::instance()->trackRequestSent('POST', $reference, '/payments'); + // Initiate checkout $response = $apiClient->initiateCheckout($checkoutData); @@ -108,6 +112,8 @@ public function initContent() throw new Exception('No checkout URL received from Flutterwave API'); } } catch (Exception $e) { + FlutterwaveSignozLogger::instance()->trackError('PAYMENT_INITIATION_FAILED', $e->getMessage(), $reference); + PrestaShopLogger::addLog( 'Flutterwave Payment Error: ' . $e->getMessage(), 3, diff --git a/flutterwavepayment/controllers/front/validation.php b/flutterwavepayment/controllers/front/validation.php index 783d17a..bc154a3 100644 --- a/flutterwavepayment/controllers/front/validation.php +++ b/flutterwavepayment/controllers/front/validation.php @@ -12,8 +12,12 @@ } use FlutterwavePayment\classes\FlutterwaveApiClient; +use FlutterwavePayment\classes\FlutterwaveSignozLogger; +use FlutterwavePayment\classes\FlutterwaveTransactionVerifier; require_once dirname(__FILE__) . '/../../classes/FlutterwaveApiClient.php'; +require_once dirname(__FILE__) . '/../../classes/FlutterwaveSignozLogger.php'; +require_once dirname(__FILE__) . '/../../classes/FlutterwaveTransactionVerifier.php'; class FlutterwavePaymentValidationModuleFrontController extends ModuleFrontController { @@ -33,25 +37,29 @@ public function postProcess() // Check if this is a return from payment page $action = Tools::getValue('status'); - $reference = Tools::getValue('reference'); if ($action === 'cancelled') { $this->errors[] = $this->module->l('Payment was cancelled.'); $this->redirectWithNotifications('index.php?controller=order&step=1'); } + // Only trust the reference minted for this cart by the payment controller. + // Never accept a reference from the query string: it lets a paid reference + // from another cart be replayed onto this one. + $reference = (string) $this->context->cookie->__get('flutterwave_reference_' . $cart->id); - if (empty($reference)) { - // Try to get reference from cookie - $reference = $this->context->cookie->__get('flutterwave_reference_' . $cart->id); - } - - if (empty($reference)) { + if (!FlutterwaveTransactionVerifier::referenceBelongsToCart($reference, $cart->id)) { + FlutterwaveSignozLogger::instance()->trackError( + 'CALLBACK_REJECTED', + 'Payment return could not be bound to a reference issued for this cart.' + ); $this->errors[] = $this->module->l('Payment reference not found.'); $this->redirectWithNotifications('index.php?controller=order&step=1'); return; } + $status = null; + try { // Initialize API client $apiClient = new FlutterwaveApiClient( @@ -66,32 +74,33 @@ public function postProcess() // Extract transaction data $transactionData = isset($response['data']) ? $response['data'] : $response; $status = isset($transactionData['status']) ? $transactionData['status'] : null; - $amount = isset($transactionData['amount']) ? $transactionData['amount'] : 0; + // Log only the fields needed to trace the payment, not customer or card details PrestaShopLogger::addLog( - 'Flutterwave Response: ' . json_encode($response), - 3, + 'Flutterwave verification for ' . $reference . ': status=' . $status + . ', id=' . (isset($transactionData['id']) ? $transactionData['id'] : '') + . ', amount=' . (isset($transactionData['amount']) ? $transactionData['amount'] : '') + . ' ' . (isset($transactionData['currency']) ? $transactionData['currency'] : ''), + 1, null, - 'Validation', + 'Cart', $cart->id, true ); + // The verified transaction must be the one minted for this cart + FlutterwaveTransactionVerifier::assertReference($transactionData, $reference); // Check transaction status - if ($status === 'successful' || $status === 'success' || $status === 'completed') { - // Verify amount matches cart total - $cartTotal = $cart->getOrderTotal(true, Cart::BOTH); - - if (abs($amount - $cartTotal) > FlutterwavePayment::AMOUNT_TOLERANCE) { - throw new Exception('Payment amount mismatch'); - } - - // Check if the currency matches + if (FlutterwaveTransactionVerifier::isSuccessful($status)) { + // Verify amount and currency match the cart $currency = new Currency($cart->id_currency); - if (isset($transactionData['currency']) && $transactionData['currency'] !== $currency->iso_code) { - throw new Exception('Payment currency mismatch'); - } + $transactionId = FlutterwaveTransactionVerifier::assertPaymentMatches( + $transactionData, + $cart->getOrderTotal(true, Cart::BOTH), + $currency->iso_code + ); + $amount = (float) $transactionData['amount']; // Check if order already exists for this cart $orderId = Order::getIdByCartId($cart->id); @@ -102,34 +111,75 @@ public function postProcess() return; } - // Create order - $currency = new Currency($cart->id_currency); - $this->module->validateOrder( - (int) $cart->id, - Configuration::get('PS_OS_PAYMENT'), - $amount, - $this->module->displayName, - null, - ['transaction_id' => $reference], - (int) $currency->id, - false, - $customer->secure_key - ); + // Serialize order creation per transaction so concurrent requests + // cannot both pass the duplicate check below. + $db = Db::getInstance(); + $lockName = pSQL('flutterwave_tx_' . $transactionId); + if (!(int) $db->getValue("SELECT GET_LOCK('" . $lockName . "', 10)", false)) { + throw new Exception('Could not acquire transaction lock'); + } - $orderId = $this->module->currentOrder; - - Db::getInstance()->insert( - 'flutterwave_transaction', - [ - 'id_order' => (int)$orderId, - 'flutterwave_transaction_id' => pSQL((string)$transactionData['id']), - 'flutterwave_reference' => pSQL($reference), - 'amount' => (float)$amount, - 'currency' => pSQL($currency->iso_code), - 'status' => pSQL($status), - 'created_at' => date('Y-m-d H:i:s'), - ] - ); + try { + // Reject a transaction or reference that already produced an order + $existingOrderId = (int) $db->getValue( + 'SELECT id_order FROM `' . _DB_PREFIX_ . 'flutterwave_transaction` + WHERE flutterwave_transaction_id = "' . pSQL($transactionId) . '" + OR flutterwave_reference = "' . pSQL($reference) . '"', + false + ); + + if ($existingOrderId) { + $existingOrder = new Order($existingOrderId); + if ((int) $existingOrder->id_cart === (int) $cart->id) { + // Same cart submitted twice, redirect to confirmation + Tools::redirect('index.php?controller=order-confirmation&id_cart=' . $cart->id . '&id_module=' . $this->module->id . '&id_order=' . $existingOrderId . '&key=' . $customer->secure_key); + return; + } + + throw new Exception('Transaction ' . $transactionId . ' already used for order #' . $existingOrderId); + } + + // Create order + $this->module->validateOrder( + (int) $cart->id, + Configuration::get('PS_OS_PAYMENT'), + $amount, + $this->module->displayName, + null, + ['transaction_id' => $transactionId], + (int) $currency->id, + false, + $customer->secure_key + ); + + $orderId = $this->module->currentOrder; + + $db->insert( + 'flutterwave_transaction', + [ + 'id_order' => (int) $orderId, + 'flutterwave_transaction_id' => pSQL($transactionId), + 'flutterwave_reference' => pSQL($reference), + 'amount' => (float) $amount, + 'currency' => pSQL($currency->iso_code), + 'status' => pSQL($status), + 'created_at' => date('Y-m-d H:i:s'), + ] + ); + } finally { + $db->getValue("SELECT RELEASE_LOCK('" . $lockName . "')", false); + } + + $signoz = FlutterwaveSignozLogger::instance(); + if ($signoz->getCurrentEnvironment() === 'production') { + $signoz->trackTransaction( + $reference, + $currency->iso_code, + $amount, + isset($transactionData['payment_type']) ? (string) $transactionData['payment_type'] : 'card', + isset($transactionData['app_fee']) ? (float) $transactionData['app_fee'] : 0.0 + ); + } // Clear cookie $this->context->cookie->__unset('flutterwave_reference_' . $cart->id); @@ -137,7 +187,7 @@ public function postProcess() // Redirect to order confirmation Tools::redirect('index.php?controller=order-confirmation&id_cart=' . $cart->id . '&id_module=' . $this->module->id . '&id_order=' . $orderId . '&key=' . $customer->secure_key); - } elseif ($status === 'pending' || $status === 'processing') { + } elseif (FlutterwaveTransactionVerifier::isPending($status)) { // Payment is still pending $this->warnings[] = $this->module->l('Your payment is being processed. Please wait...'); $this->redirectWithNotifications('index.php?controller=order&step=1'); @@ -147,6 +197,12 @@ public function postProcess() throw new Exception($errorMessage); } } catch (Exception $e) { + FlutterwaveSignozLogger::instance()->trackError( + FlutterwaveTransactionVerifier::isFailed($status) ? 'PAYMENT_FAILED' : 'PAYMENT_VERIFICATION_FAILED', + $e->getMessage(), + $reference + ); + PrestaShopLogger::addLog( 'Flutterwave Payment Validation Error: ' . $e->getMessage(), 3, diff --git a/flutterwavepayment/controllers/front/webhook.php b/flutterwavepayment/controllers/front/webhook.php index 31363d5..6a3f123 100644 --- a/flutterwavepayment/controllers/front/webhook.php +++ b/flutterwavepayment/controllers/front/webhook.php @@ -12,8 +12,12 @@ } use FlutterwavePayment\classes\FlutterwaveApiClient; +use FlutterwavePayment\classes\FlutterwaveSignozLogger; +use FlutterwavePayment\classes\FlutterwaveTransactionVerifier; require_once dirname(__FILE__) . '/../../classes/FlutterwaveApiClient.php'; +require_once dirname(__FILE__) . '/../../classes/FlutterwaveSignozLogger.php'; +require_once dirname(__FILE__) . '/../../classes/FlutterwaveTransactionVerifier.php'; class FlutterwavePaymentWebhookModuleFrontController extends ModuleFrontController @@ -30,6 +34,8 @@ public function postProcess() die(json_encode(['status' => 'error', 'message' => 'Empty payload'])); } + $signoz = FlutterwaveSignozLogger::instance(); + if ( ! isset($_SERVER['HTTP_VERIF_HASH']) || empty($_SERVER['HTTP_VERIF_HASH'])) { PrestaShopLogger::addLog( 'Flutterwave Webhook: Missing signature', @@ -47,8 +53,14 @@ public function postProcess() $signature = $_SERVER['HTTP_VERIF_HASH']; $secret = $this->module->getWebhookSecret(); - // Verify signature - if ($signature !== $secret) { + // Only authenticates the sender; payment details are requeried below + if (!FlutterwaveApiClient::verifyWebhookHash($signature, $secret)) { + // Forged webhooks are not reported: anyone can send them. A missing + // secret is a store misconfiguration, and trackError throttles it. + if (empty($secret)) { + $signoz->trackError('WEBHOOK_SECRET_HASH_MISSING', 'Webhook rejected because the store has no webhook secret configured.'); + } + PrestaShopLogger::addLog( 'Flutterwave Webhook: Invalid signature', 3, @@ -66,13 +78,15 @@ public function postProcess() $webhookData = json_decode($payload, true); if (json_last_error() !== JSON_ERROR_NONE) { + $signoz->trackError('WEBHOOK_BODY_DEFORMED', 'Webhook body is not valid JSON: ' . json_last_error_msg()); http_response_code(400); die(json_encode(['status' => 'error', 'message' => 'Invalid JSON'])); } - // Log webhook received + // Log webhook received, without customer or card details PrestaShopLogger::addLog( - 'Flutterwave Webhook received: ' . json_encode($webhookData), + 'Flutterwave Webhook received: event=' . (isset($webhookData['event']) ? $webhookData['event'] : '') + . ', tx_ref=' . (isset($webhookData['data']['tx_ref']) ? $webhookData['data']['tx_ref'] : ''), 1, null, 'FlutterwavePayment', @@ -81,33 +95,33 @@ public function postProcess() ); try { - // Extract event data - $event = isset($webhookData['event']) ? $webhookData['event'] : null; + // Extract the reference only; everything else in the body is untrusted $data = isset($webhookData['data']) ? $webhookData['data'] : $webhookData; - - $reference = isset($data['tx_ref']) ? $data['tx_ref'] : null; - $status = isset($data['status']) ? $data['status'] : null; - $amount = isset($data['amount']) ? $data['amount'] : 0; + $reference = isset($data['tx_ref']) ? (string) $data['tx_ref'] : null; if (empty($reference)) { throw new Exception('Reference not found in webhook data'); } - // Extract cart ID from reference or metadata - $cartId = null; - - if (isset($data['metadata']['cart_id'])) { - $cartId = (int) $data['metadata']['cart_id']; - } else { - // Try to extract from reference (format: {PREFIX}CARTID_TIMESTAMP_RANDOM) - $pattern = '/^' . preg_quote(FlutterwavePayment::REFERENCE_PREFIX, '/') . '(\d+)_/'; - if (preg_match($pattern, $reference, $matches)) { - $cartId = (int) $matches[1]; - } - } + // Requery the transaction at the Flutterwave API + $apiClient = new FlutterwaveApiClient( + $this->module->getApiUrl(), + $this->module->getSecretKey(), + $this->module->getPublicKey() + ); + + $response = $apiClient->verifyTransaction($reference); + $transactionData = isset($response['data']) ? $response['data'] : []; + + FlutterwaveTransactionVerifier::assertReference($transactionData, $reference); + + $status = isset($transactionData['status']) ? $transactionData['status'] : null; + + // Extract cart ID from the verified reference + $cartId = FlutterwaveTransactionVerifier::cartIdFromReference($reference); if (empty($cartId)) { - throw new Exception('Cart ID not found in webhook data'); + throw new Exception('Cart ID not found in webhook reference'); } // Check if order already exists @@ -116,8 +130,27 @@ public function postProcess() if ($orderId) { $order = new Order($orderId); - // Update order status based on webhook event - if ($status === 'successful' || $status === 'success' || $status === 'completed') { + // Update order status based on the verified transaction + if (FlutterwaveTransactionVerifier::isSuccessful($status)) { + $orderCurrency = new Currency($order->id_currency); + $transactionId = FlutterwaveTransactionVerifier::assertPaymentMatches( + $transactionData, + $order->total_paid, + $orderCurrency->iso_code + ); + + // The charge must not already be recorded against a different order + $linkedOrderId = (int) Db::getInstance()->getValue( + 'SELECT id_order FROM `' . _DB_PREFIX_ . 'flutterwave_transaction` + WHERE flutterwave_transaction_id = "' . pSQL($transactionId) . '" + OR flutterwave_reference = "' . pSQL($reference) . '"', + false + ); + + if ($linkedOrderId && $linkedOrderId !== (int) $orderId) { + throw new Exception('Transaction ' . $transactionId . ' is not linked to order #' . $orderId); + } + // Payment successful - ensure order is marked as paid if ($order->getCurrentState() != Configuration::get('PS_OS_PAYMENT')) { $order->setCurrentState(Configuration::get('PS_OS_PAYMENT')); @@ -131,10 +164,16 @@ public function postProcess() true ); } - } elseif ($status === 'failed' || $status === 'declined') { + } elseif (FlutterwaveTransactionVerifier::isFailed($status)) { // Payment failed if ($order->getCurrentState() != Configuration::get('PS_OS_ERROR')) { $order->setCurrentState(Configuration::get('PS_OS_ERROR')); + + $signoz->trackError( + 'PAYMENT_FAILED', + isset($transactionData['processor_response']) ? (string) $transactionData['processor_response'] : 'Payment failed', + $reference + ); PrestaShopLogger::addLog( 'Flutterwave Webhook: Order #' . $orderId . ' marked as failed', @@ -165,6 +204,8 @@ public function postProcess() http_response_code(200); die(json_encode(['status' => 'success', 'message' => 'Webhook processed'])); } catch (Exception $e) { + $signoz->trackError('WEBHOOK_PROCESSING_FAILED', $e->getMessage(), isset($reference) ? (string) $reference : ''); + PrestaShopLogger::addLog( 'Flutterwave Webhook Error: ' . $e->getMessage(), 3, diff --git a/flutterwavepayment/flutterwavepayment.php b/flutterwavepayment/flutterwavepayment.php index 7c645ee..8dcea9d 100644 --- a/flutterwavepayment/flutterwavepayment.php +++ b/flutterwavepayment/flutterwavepayment.php @@ -11,19 +11,23 @@ exit; } +require_once dirname(__FILE__) . '/classes/FlutterwaveTransactionVerifier.php'; +require_once dirname(__FILE__) . '/classes/FlutterwaveSignozLogger.php'; + +use FlutterwavePayment\classes\FlutterwaveSignozLogger; class FlutterwavePayment extends \PaymentModule { const FLUTTERWAVE_PRODUCTION_URL = 'https://api.flutterwave.com/v3'; const FLUTTERWAVE_SANDBOX_URL = 'https://api.flutterwave.com/v3'; - const REFERENCE_PREFIX = 'PS_'; - const AMOUNT_TOLERANCE = 0.01; + const REFERENCE_PREFIX = \FlutterwavePayment\classes\FlutterwaveTransactionVerifier::REFERENCE_PREFIX; + const AMOUNT_TOLERANCE = \FlutterwavePayment\classes\FlutterwaveTransactionVerifier::AMOUNT_TOLERANCE; public function __construct() { $this->name = 'flutterwavepayment'; $this->tab = 'payments_gateways'; - $this->version = '1.0.0'; + $this->version = '1.1.0'; $this->author = 'Flutterwave Developers'; $this->need_instance = 0; $this->ps_versions_compliancy = [ @@ -50,9 +54,11 @@ public function install() && $this->registerHook('paymentOptions') && $this->registerHook('displayPaymentReturn') && $this->registerHook('actionOrderStatusUpdate') + && $this->registerHook('displayBackOfficeHeader') && $this->installAdminTab() && $this->installDatabase() - && $this->installRefundDatabase(); + && $this->installRefundDatabase() + && $this->installSignozDatabase(); // && Configuration::updateValue('FLUTTERWAVE_LIVE_MODE', 0) // && Configuration::updateValue('FLUTTERWAVE_PUBLIC_KEY', '') // && Configuration::updateValue('FLUTTERWAVE_SECRET_KEY', '') @@ -96,7 +102,11 @@ public function uninstall() && Configuration::deleteByName('FLUTTERWAVE_LIVE_MODE') && Configuration::deleteByName('FLUTTERWAVE_PUBLIC_KEY') && Configuration::deleteByName('FLUTTERWAVE_SECRET_KEY') - && Configuration::deleteByName('FLUTTERWAVE_WEBHOOK_SECRET'); + && Configuration::deleteByName('FLUTTERWAVE_WEBHOOK_SECRET') + && Configuration::deleteByName(FlutterwaveSignozLogger::CONFIG_APP_ID) + && Configuration::deleteByName(FlutterwaveSignozLogger::CONFIG_APP_REGISTERED) + && Configuration::deleteByName(FlutterwaveSignozLogger::CONFIG_REGISTERED_KEY) + && Configuration::deleteByName(FlutterwaveSignozLogger::CONFIG_REGISTERED_VERSION); } private function installDatabase() @@ -117,7 +127,7 @@ private function installDatabase() `created_at` DATETIME NOT NULL, PRIMARY KEY (`id_flutterwave_transaction`), UNIQUE KEY `uniq_order` (`id_order`), - KEY `idx_transaction_id` (`flutterwave_transaction_id`), + UNIQUE KEY `uniq_transaction_id` (`flutterwave_transaction_id`), KEY `idx_reference` (`flutterwave_reference`) ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4; '; @@ -143,6 +153,25 @@ private function installRefundDatabase() return Db::getInstance()->execute($sql); } + /** + * TTL key/value store used by the SigNoz logger for its circuit breaker, + * health cache and per-reference trace contexts. + */ + public function installSignozDatabase() + { + $sql = ' + CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . FlutterwaveSignozLogger::STATE_TABLE . '` ( + `name` VARCHAR(64) NOT NULL, + `value` TEXT NOT NULL, + `expires_at` INT UNSIGNED NOT NULL, + PRIMARY KEY (`name`), + KEY `idx_expires_at` (`expires_at`) + ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4; + '; + + return Db::getInstance()->execute($sql); + } + private function uninstallDatabase() { $sql = ' @@ -155,6 +184,12 @@ private function uninstallDatabase() DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'flutterwave_refund`; '; + $result = $result && Db::getInstance()->execute($sql); + + $sql = ' + DROP TABLE IF EXISTS `' . _DB_PREFIX_ . FlutterwaveSignozLogger::STATE_TABLE . '`; + '; + return $result && Db::getInstance()->execute($sql); } @@ -201,12 +236,26 @@ public function getContent() Configuration::updateValue('FLUTTERWAVE_SECRET_KEY', $flutterwaveSecretKey); Configuration::updateValue('FLUTTERWAVE_WEBHOOK_SECRET', $flutterwaveWebhookSecret); $output .= $this->displayConfirmation($this->l('Settings updated')); + + FlutterwaveSignozLogger::instance()->registerApp($flutterwavePublicKey, $this->version); } } return $output . $this->displayForm(); } + /** + * Catch-all SigNoz registration for merchants who upgrade the module + * without re-saving its settings. The network call runs after the page + * has been sent. + */ + public function hookDisplayBackOfficeHeader($params) + { + FlutterwaveSignozLogger::instance()->maybeRegisterInBackground($this->version); + + return ''; + } + public function hookActionOrderStatusUpdate($params) { // Refunds are handled manually through the diff --git a/flutterwavepayment/upgrade/index.php b/flutterwavepayment/upgrade/index.php new file mode 100644 index 0000000..81b7fd1 --- /dev/null +++ b/flutterwavepayment/upgrade/index.php @@ -0,0 +1,18 @@ +executeS( + 'SELECT flutterwave_transaction_id, GROUP_CONCAT(id_order) AS orders + FROM `' . $table . '` + GROUP BY flutterwave_transaction_id + HAVING COUNT(*) > 1' + ); + + if (!empty($duplicates)) { + foreach ($duplicates as $row) { + PrestaShopLogger::addLog( + 'Flutterwave upgrade: transaction ' . $row['flutterwave_transaction_id'] + . ' is linked to multiple orders (' . $row['orders'] . ')', + 3, + null, + 'FlutterwavePayment', + null, + true + ); + } + + return false; + } + + $hasOldIndex = $db->executeS('SHOW INDEX FROM `' . $table . '` WHERE Key_name = "idx_transaction_id"'); + + return $db->execute( + 'ALTER TABLE `' . $table . '`' + . ($hasOldIndex ? ' DROP INDEX `idx_transaction_id`,' : '') + . ' ADD UNIQUE KEY `uniq_transaction_id` (`flutterwave_transaction_id`)' + ); +} diff --git a/flutterwavepayment/upgrade/upgrade-1.1.0.php b/flutterwavepayment/upgrade/upgrade-1.1.0.php new file mode 100644 index 0000000..145e293 --- /dev/null +++ b/flutterwavepayment/upgrade/upgrade-1.1.0.php @@ -0,0 +1,20 @@ +installSignozDatabase() + && $module->registerHook('displayBackOfficeHeader'); +} diff --git a/flutterwavepayment/views/templates/admin/index.php b/flutterwavepayment/views/templates/admin/index.php new file mode 100644 index 0000000..81b7fd1 --- /dev/null +++ b/flutterwavepayment/views/templates/admin/index.php @@ -0,0 +1,18 @@ + - app - src + flutterwavepayment/classes diff --git a/tests/Pest.php b/tests/Pest.php index d4ffffb..ef95ff0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,11 @@ use Tests\TestCase; +// Module files exit unless loaded inside PrestaShop +if (!defined('_PS_VERSION_')) { + define('_PS_VERSION_', '8.2.0'); +} + /* |-------------------------------------------------------------------------- | Test Case diff --git a/tests/Support/FakeFlutterwaveApiClient.php b/tests/Support/FakeFlutterwaveApiClient.php new file mode 100644 index 0000000..fb6ee67 --- /dev/null +++ b/tests/Support/FakeFlutterwaveApiClient.php @@ -0,0 +1,50 @@ + */ + public array $requests = []; + + /** @var array */ + private array $responses = []; + + public function __construct(string $apiUrl = 'https://api.flutterwave.com/v3', string $secretKey = 'FLWSECK_TEST-secret', string $publicKey = 'FLWPUBK_TEST-public') + { + parent::__construct($apiUrl, $secretKey, $publicKey); + } + + public function respondWith(array|string|false $body, int $httpCode = 200, string $curlError = ''): self + { + $this->responses[] = [is_array($body) ? json_encode($body) : $body, $httpCode, $curlError]; + + return $this; + } + + public function lastRequest(): array + { + return end($this->requests); + } + + public function lastPayload(): array + { + return json_decode($this->lastRequest()['body'], true); + } + + protected function sendHttpRequest($method, $url, array $headers, $body = null) + { + $this->requests[] = compact('method', 'url', 'headers', 'body'); + + if (!$this->responses) { + throw new \LogicException('No fake response queued for ' . $method . ' ' . $url); + } + + return array_shift($this->responses); + } +} diff --git a/tests/Support/FakeSignozLogger.php b/tests/Support/FakeSignozLogger.php new file mode 100644 index 0000000..2ebeea5 --- /dev/null +++ b/tests/Support/FakeSignozLogger.php @@ -0,0 +1,151 @@ + */ + public array $config = []; + + /** @var array */ + public array $state = []; + + /** @var array */ + public array $requests = []; + + /** @var array */ + private array $eventResponses = []; + + /** @var array{0: string|false, 1: int, 2: string} */ + private array $healthResponse = ['{"status":"ok"}', 200, '']; + + public int $sleeps = 0; + + /** Fake clock, in seconds. */ + public float $clock = 1000.0; + + /** Simulated latency (ms) of every HTTP call; a call slower than its timeout fails. */ + public int $latencyMs = 0; + + public function __construct() + { + parent::__construct(); + } + + public static function make(array $config = []): self + { + $logger = new self(); + $logger->config = $config; + + return $logger; + } + + public function respondToEvents(array|string|false $body, int $httpCode = 200, string $error = ''): self + { + $this->eventResponses[] = [is_array($body) ? json_encode($body) : $body, $httpCode, $error]; + + return $this; + } + + public function respondToHealth(array|string|false $body, int $httpCode = 200, string $error = ''): self + { + $this->healthResponse = [is_array($body) ? json_encode($body) : $body, $httpCode, $error]; + + return $this; + } + + /** @return array */ + public function sentEvents(): array + { + $events = []; + foreach ($this->requests as $request) { + if ($request['method'] === 'POST') { + $events[] = json_decode($request['body'], true); + } + } + + return $events; + } + + public function healthChecks(): int + { + return count(array_filter($this->requests, fn ($r) => str_ends_with($r['url'], self::HEALTH_PATH))); + } + + protected function getConfig($key) + { + return $this->config[$key] ?? false; + } + + protected function updateConfig($key, $value) + { + $this->config[$key] = $value; + } + + protected function stateGet($name) + { + if (!isset($this->state[$name]) || $this->state[$name]['expires_at'] < time()) { + return null; + } + + return json_decode(json_encode($this->state[$name]['value']), true); + } + + protected function stateSet($name, $value, $ttl) + { + $this->state[$name] = ['value' => $value, 'expires_at' => time() + $ttl]; + } + + protected function stateDelete($name) + { + unset($this->state[$name]); + } + + protected function purgeExpiredState() + { + $this->state = array_filter($this->state, fn ($entry) => $entry['expires_at'] >= time()); + } + + protected function libraryVersion() + { + return '1.1.0'; + } + + protected function backoffSleep($attempt) + { + $this->sleeps++; + } + + protected function now() + { + return $this->clock; + } + + protected function sendHttpRequest($method, $url, array $headers, $body, $timeoutMs) + { + $this->requests[] = compact('method', 'url', 'headers', 'body', 'timeoutMs'); + + if ($this->latencyMs > $timeoutMs) { + $this->clock += $timeoutMs / 1000; + return [false, 0, 'Operation timed out after ' . $timeoutMs . ' milliseconds']; + } + + $this->clock += $this->latencyMs / 1000; + + if ($method === 'GET') { + return $this->healthResponse; + } + + if (!$this->eventResponses) { + return ['{}', 200, '']; + } + + return array_shift($this->eventResponses); + } +} diff --git a/tests/Unit/PaymentClientTest.php b/tests/Unit/PaymentClientTest.php index 7ac4d6c..ae98532 100644 --- a/tests/Unit/PaymentClientTest.php +++ b/tests/Unit/PaymentClientTest.php @@ -1,29 +1,108 @@ initiateCheckout([ +function checkoutData(array $overrides = []): array +{ + return array_replace_recursive([ 'amount' => 100, 'currency' => 'USD', - 'reference' => 'ORD123', + 'reference' => 'PS_1_1700000000_abcd1234', 'callback_url' => 'https://example.com/callback', 'return_url' => 'https://example.com/return', 'customer' => [ 'name' => 'John Doe', 'email' => 'john.doe@example.com', - 'phone' => '1234567890' + 'phone' => '1234567890', ], 'metadata' => [ 'cart_id' => '1', 'customer_id' => '1', - 'shop_name' => 'Test Shop' + 'shop_name' => 'Test Shop', ], - ]); + ], $overrides); +} + +it('builds correct payment payload', function () { + $client = (new FakeFlutterwaveApiClient()) + ->respondWith(['status' => 'success', 'data' => ['link' => 'https://checkout.flutterwave.com/pay/abc']]); + + $response = $client->initiateCheckout(checkoutData()); + + $request = $client->lastRequest(); + expect($request['method'])->toBe('POST') + ->and($request['url'])->toBe('https://api.flutterwave.com/v3/payments') + ->and($request['headers'])->toContain('Authorization: Bearer FLWSECK_TEST-secret') + ->and($request['headers'])->toContain('Content-Type: application/json'); + + $payload = $client->lastPayload(); + expect($payload['amount'])->toBe(100) + ->and($payload['currency'])->toBe('USD') + ->and($payload['tx_ref'])->toBe('PS_1_1700000000_abcd1234') + ->and($payload['redirect_url'])->toBe('https://example.com/return') + ->and($payload['customer'])->toBe(['email' => 'john.doe@example.com', 'name' => 'John Doe']) + ->and($payload['customizations']['title'])->toBe('Test Shop Order #1'); + + expect($response['data']['link'])->toBe('https://checkout.flutterwave.com/pay/abc'); +}); + +it('verifies a transaction by url-encoded reference', function () { + $client = (new FakeFlutterwaveApiClient('https://api.flutterwave.com/v3/')) + ->respondWith(['status' => 'success', 'data' => ['tx_ref' => 'PS_1_1&x=y']]); + + $client->verifyTransaction('PS_1_1&x=y'); + + $request = $client->lastRequest(); + expect($request['method'])->toBe('GET') + ->and($request['url'])->toBe('https://api.flutterwave.com/v3/transactions/verify_by_reference?tx_ref=PS_1_1%26x%3Dy') + ->and($request['body'])->toBeNull(); +}); + +it('sends a partial refund amount', function () { + $client = (new FakeFlutterwaveApiClient())->respondWith(['status' => 'success']); + + $client->refundTransaction('12345', 25.5); + + expect($client->lastRequest()['url'])->toBe('https://api.flutterwave.com/v3/transactions/12345/refund') + ->and($client->lastPayload())->toBe(['amount' => 25.5]); +}); + +it('sends a full refund without an amount', function () { + $client = (new FakeFlutterwaveApiClient())->respondWith(['status' => 'success']); + + $client->refundTransaction('12345'); + + expect($client->lastRequest()['body'])->toBe('[]'); +}); + +it('throws the API message on an HTTP error', function () { + $client = (new FakeFlutterwaveApiClient()) + ->respondWith(['status' => 'error', 'message' => 'Invalid authorization key'], 401); + + $client->verifyTransaction('PS_1_1'); +})->throws(Exception::class, 'Invalid authorization key'); + +it('throws an encoded error object when there is no message', function () { + $client = (new FakeFlutterwaveApiClient()) + ->respondWith(['error' => ['code' => 'E500']], 500); + + $client->verifyTransaction('PS_1_1'); +})->throws(Exception::class, '{"code":"E500"}'); + +it('throws on a cURL error', function () { + $client = (new FakeFlutterwaveApiClient())->respondWith(false, 0, 'Could not resolve host'); + + $client->verifyTransaction('PS_1_1'); +})->throws(Exception::class, 'cURL Error: Could not resolve host'); + +it('throws on an empty response', function () { + $client = (new FakeFlutterwaveApiClient())->respondWith(''); + + $client->verifyTransaction('PS_1_1'); +})->throws(Exception::class, 'Empty response from Flutterwave API'); + +it('throws on invalid JSON', function () { + $client = (new FakeFlutterwaveApiClient())->respondWith('Bad gateway', 502); - expect($payload['amount'])->toBe(100); - expect($payload['currency'])->toBe('USD'); - expect($payload['tx_ref'])->toBe('ORD123'); -}); \ No newline at end of file + $client->verifyTransaction('PS_1_1'); +})->throws(Exception::class, 'Invalid JSON response from Flutterwave API'); diff --git a/tests/Unit/PaymentServiceTest.php b/tests/Unit/PaymentServiceTest.php deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Unit/SignatureValidatorTest.php b/tests/Unit/SignatureValidatorTest.php index e69de29..2491b5b 100644 --- a/tests/Unit/SignatureValidatorTest.php +++ b/tests/Unit/SignatureValidatorTest.php @@ -0,0 +1,29 @@ +toBeTrue(); +}); + +it('rejects a different secret hash', function (string $header) { + expect(FlutterwaveApiClient::verifyWebhookHash($header, 'my-dashboard-secret'))->toBeFalse(); +})->with([ + 'wrong value' => 'another-secret', + 'prefix only' => 'my-dashboard', + 'different case' => 'MY-DASHBOARD-SECRET', + 'hmac of body' => hash_hmac('sha256', '{"event":"charge.completed"}', 'my-dashboard-secret'), +]); + +it('fails closed when no secret is configured', function (?string $secret) { + expect(FlutterwaveApiClient::verifyWebhookHash('', $secret))->toBeFalse() + ->and(FlutterwaveApiClient::verifyWebhookHash('anything', $secret))->toBeFalse(); +})->with([ + 'empty string' => '', + 'null' => null, +]); + +it('rejects a missing header', function () { + expect(FlutterwaveApiClient::verifyWebhookHash('', 'my-dashboard-secret'))->toBeFalse() + ->and(FlutterwaveApiClient::verifyWebhookHash(null, 'my-dashboard-secret'))->toBeFalse(); +}); diff --git a/tests/Unit/SignozLoggerTest.php b/tests/Unit/SignozLoggerTest.php new file mode 100644 index 0000000..15d9e65 --- /dev/null +++ b/tests/Unit/SignozLoggerTest.php @@ -0,0 +1,374 @@ + SIGNOZ_PUBLIC_KEY, + 'FLUTTERWAVE_LIVE_MODE' => 0, + ], $config)); +} + +describe('deferred events', function () { + it('buffers events until flush and stamps them when tracked', function () { + $logger = signozLogger(); + + $logger->trackRequestSent('POST', 'PS_42_1700000000_abcd1234', '/payments'); + + expect($logger->requests)->toBeEmpty() + ->and($logger->getPendingEvents())->toHaveCount(1); + + $logger->flush(); + + $events = $logger->sentEvents(); + expect($events)->toHaveCount(1) + ->and($events[0]['name'])->toBe('request.sent') + ->and($events[0]['timestamp'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/') + ->and($events[0]['data'])->toMatchArray([ + 'app_id' => SIGNOZ_PUBLIC_KEY, + 'environment' => 'sandbox', + 'library' => 'PrestaShop', + 'library_version' => '1.1.0', + 'method' => 'POST', + 'path' => '/payments', + 'reference' => 'PS_42_1700000000_abcd1234', + ]) + ->and($logger->getPendingEvents())->toBeEmpty(); + }); + + it('sends request.sent once per reference', function () { + $logger = signozLogger(); + + $logger->trackRequestSent('POST', 'PS_42_1', '/payments'); + $logger->trackRequestSent('POST', 'PS_42_1', '/payments'); + + expect($logger->getPendingEvents())->toHaveCount(1); + }); + + it('reports production when live mode is on', function () { + expect(signozLogger(['FLUTTERWAVE_LIVE_MODE' => 1])->getCurrentEnvironment())->toBe('production') + ->and(signozLogger()->getCurrentEnvironment())->toBe('sandbox'); + }); + + it('truncates error messages and stack traces', function () { + $logger = signozLogger(); + + $logger->trackError('PAYMENT_FAILED', str_repeat('a', 5000), 'PS_1_2', null, str_repeat('b', 20000)); + $data = $logger->getPendingEvents()[0][1]; + + expect(strlen($data['error_message']))->toBe(Logger::ERROR_MESSAGE_MAX_LENGTH) + ->and(strlen($data['error_stacktrace']))->toBe(Logger::ERROR_STACKTRACE_MAX_LENGTH) + ->and($data['reference'])->toBe('PS_1_2'); + }); + + it('omits the reference on errors without one', function () { + $logger = signozLogger(); + + $logger->trackError('WEBHOOK_SIGNATURE_MISMATCH', 'nope'); + + expect($logger->getPendingEvents()[0][1])->not->toHaveKey('reference'); + }); + + it('throttles unreferenced errors per error code', function () { + $logger = signozLogger(); + + $logger->trackError('WEBHOOK_SECRET_HASH_MISSING', 'no secret'); + $logger->trackError('WEBHOOK_SECRET_HASH_MISSING', 'no secret'); + $logger->trackError('CALLBACK_REJECTED', 'not bound'); + + expect(array_column(array_column($logger->getPendingEvents(), 1), 'error_code')) + ->toBe(['WEBHOOK_SECRET_HASH_MISSING', 'CALLBACK_REJECTED']); + }); + + it('sends an unreferenced error again once the window expires', function () { + $logger = signozLogger(); + + $logger->trackError('CALLBACK_REJECTED', 'not bound'); + $logger->state[Logger::ERROR_THROTTLE_KEY_PREFIX . md5('CALLBACK_REJECTED')]['expires_at'] = time() - 1; + $logger->trackError('CALLBACK_REJECTED', 'not bound'); + + expect($logger->getPendingEvents())->toHaveCount(2); + }); + + it('does not throttle errors tied to a reference', function () { + $logger = signozLogger(); + + $logger->trackError('PAYMENT_FAILED', 'declined', 'PS_1_1'); + $logger->trackError('PAYMENT_FAILED', 'declined', 'PS_2_1'); + + expect($logger->getPendingEvents())->toHaveCount(2); + }); + + it('normalizes references', function () { + $logger = signozLogger(); + + $logger->trackTransaction(' PS_1 2/../x ', 'NGN', 10, 'card', 0.14); + + expect($logger->getPendingEvents()[0][1]['reference'])->toBe('PS_1-2-x'); + }); +}); + +describe('trace context', function () { + it('links events for the same reference into one trace', function () { + $logger = signozLogger(); + + $logger->trackRequestSent('POST', 'PS_7_1', '/payments'); + $logger->trackTransaction('PS_7_1', 'NGN', 100, 'card', 1.4); + + [$first, $second] = array_map(fn ($e) => $e[1]['trace_context'], $logger->getPendingEvents()); + + expect($first['trace_id'])->toMatch('/^[0-9a-f]{32}$/') + ->and($first['span_id'])->toMatch('/^[0-9a-f]{16}$/') + ->and($first)->not->toHaveKey('parent_span_id') + ->and($second['trace_id'])->toBe($first['trace_id']) + ->and($second['parent_span_id'])->toBe($first['span_id']) + ->and($second['span_id'])->not->toBe($first['span_id']); + }); + + it('survives across requests through the state store', function () { + $checkout = signozLogger(); + $checkout->trackRequestSent('POST', 'PS_7_1', '/payments'); + $root = $checkout->getPendingEvents()[0][1]['trace_context']; + + $webhook = signozLogger(); + $webhook->state = $checkout->state; + $webhook->trackError('PAYMENT_FAILED', 'declined', 'PS_7_1'); + + expect($webhook->getPendingEvents()[0][1]['trace_context']['trace_id'])->toBe($root['trace_id']); + }); + + it('honours a W3C traceparent', function () { + $logger = signozLogger(); + + $logger->trackError('X', 'y', 'PS_1_1', ['traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01']); + + expect($logger->getPendingEvents()[0][1]['trace_context'])->toMatchArray([ + 'trace_id' => '0af7651916cd43dd8448eb211c80319c', + 'parent_span_id' => 'b7ad6b7169203331', + ]); + }); +}); + +describe('app registration', function () { + it('stores the backend app_id and uses it on later events', function () { + $logger = signozLogger()->respondToEvents(['app_id' => 'app 123']); + + expect($logger->registerApp(SIGNOZ_PUBLIC_KEY, '1.1.0'))->toBe('app-123') + ->and($logger->config[Logger::CONFIG_APP_ID])->toBe('app-123') + ->and($logger->isAppRegistered('1.1.0'))->toBeTrue() + ->and($logger->isAppRegistered('1.2.0'))->toBeFalse(); + + $created = $logger->sentEvents()[0]; + expect($created['name'])->toBe('app.created') + ->and($created['data'])->toMatchArray(['public_key' => SIGNOZ_PUBLIC_KEY, 'library' => 'PrestaShop']); + + $logger->trackError('X', 'y'); + expect($logger->getPendingEvents()[0][1]['app_id'])->toBe('app-123'); + }); + + it('does not call the service again once an app_id is stored', function () { + $logger = signozLogger([Logger::CONFIG_APP_ID => 'app-1', Logger::CONFIG_REGISTERED_KEY => SIGNOZ_PUBLIC_KEY]); + + expect($logger->registerApp(SIGNOZ_PUBLIC_KEY, '1.1.0'))->toBe('app-1') + ->and($logger->requests)->toBeEmpty(); + }); + + it('registers a new app when the public key changes', function () { + $logger = signozLogger([ + Logger::CONFIG_APP_ID => 'app-old', + Logger::CONFIG_APP_REGISTERED => 1, + Logger::CONFIG_REGISTERED_KEY => 'FLWPUBK_TEST-old', + ])->respondToEvents(['app_id' => 'app-new']); + + expect($logger->registerApp(SIGNOZ_PUBLIC_KEY, '1.1.0'))->toBe('app-new') + ->and($logger->config[Logger::CONFIG_REGISTERED_KEY])->toBe(SIGNOZ_PUBLIC_KEY); + }); + + it('leaves registration pending when the service is down', function () { + $logger = signozLogger()->respondToHealth('', 0, 'Could not resolve host'); + + expect($logger->registerApp(SIGNOZ_PUBLIC_KEY, '1.1.0'))->toBeNull() + ->and($logger->isAppRegistered('1.1.0'))->toBeFalse(); + }); + + it('throttles background registration attempts', function () { + $logger = signozLogger(); + + $logger->maybeRegisterInBackground('1.1.0'); + expect($logger->state)->toHaveKey(Logger::REGISTER_ATTEMPT_KEY); + + $logger->state[Logger::REGISTER_ATTEMPT_KEY]['value'] = 'marker'; + $logger->maybeRegisterInBackground('1.1.0'); + expect($logger->state[Logger::REGISTER_ATTEMPT_KEY]['value'])->toBe('marker'); + }); + + it('skips background registration without a public key', function () { + $logger = signozLogger(['FLUTTERWAVE_PUBLIC_KEY' => '']); + + $logger->maybeRegisterInBackground('1.1.0'); + + expect($logger->state)->toBeEmpty(); + }); +}); + +describe('transport', function () { + it('caches a passing health check', function () { + $logger = signozLogger(); + + $logger->trackError('A', 'a'); + $logger->trackError('B', 'b'); + $logger->flush(); + + expect($logger->healthChecks())->toBe(1) + ->and($logger->sentEvents())->toHaveCount(2); + }); + + it('drops events when the health check fails', function () { + $logger = signozLogger()->respondToHealth(['status' => 'starting']); + + $logger->trackError('A', 'a'); + $logger->flush(); + + expect($logger->sentEvents())->toBeEmpty(); + }); + + it('retries 503 responses with backoff on blocking sends', function () { + $logger = signozLogger() + ->respondToEvents('', 503) + ->respondToEvents('', 503) + ->respondToEvents(['app_id' => 'app-1'], 202); + + expect($logger->registerApp(SIGNOZ_PUBLIC_KEY, '1.1.0'))->toBe('app-1') + ->and($logger->sentEvents())->toHaveCount(3) + ->and($logger->sleeps)->toBe(2) + ->and($logger->state)->not->toHaveKey(Logger::CB_FAILURES_KEY); + }); + + it('does not retry 503 responses during a flush', function () { + $logger = signozLogger()->respondToEvents('', 503); + + $logger->trackError('A', 'a'); + $logger->flush(); + + expect($logger->sentEvents())->toHaveCount(1) + ->and($logger->sleeps)->toBe(0); + }); + + it('uses short timeouts during a flush and longer ones for blocking sends', function () { + $logger = signozLogger()->respondToEvents(['app_id' => 'app-1']); + + $logger->registerApp(SIGNOZ_PUBLIC_KEY, '1.1.0'); + $logger->trackError('A', 'a'); + $logger->flush(); + + expect(array_column($logger->requests, 'timeoutMs'))->toBe([ + Logger::HEALTH_TIMEOUT_MS, + Logger::BLOCKING_TIMEOUT_MS, + Logger::EVENT_TIMEOUT_MS, + ]); + }); + + it('stops flushing once the time budget is spent', function () { + $logger = signozLogger(); + $logger->latencyMs = 600; + + foreach (range(1, 5) as $i) { + $logger->trackError('E' . $i, 'boom'); + } + $start = $logger->clock; + $logger->flush(); + + // Health (600ms) + 1 event (600ms) leaves 300ms: one more event fits + // with a clipped timeout, which times out without blaming the service. + expect(($logger->clock - $start) * 1000)->toBeLessThanOrEqual(Logger::FLUSH_BUDGET_MS) + ->and($logger->sentEvents())->toHaveCount(2) + ->and($logger->state)->not->toHaveKey(Logger::CB_FAILURES_KEY) + ->and($logger->getPendingEvents())->toBeEmpty(); + }); + + it('treats a response slower than the timeout as a failure', function () { + $logger = signozLogger(); + $logger->state[Logger::HEALTH_OK_KEY] = ['value' => true, 'expires_at' => time() + 60]; + $logger->latencyMs = Logger::EVENT_TIMEOUT_MS + 500; + + $logger->trackError('A', 'a'); + $logger->flush(); + + expect($logger->state[Logger::CB_FAILURES_KEY]['value'])->toBe(1); + }); + + it('opens the circuit when the service is persistently slow', function () { + $state = [Logger::HEALTH_OK_KEY => ['value' => true, 'expires_at' => time() + 60]]; + + foreach (range(1, Logger::CB_FAILURE_THRESHOLD) as $i) { + $logger = signozLogger(); + $logger->state = $state; + $logger->latencyMs = 5000; + $logger->trackError('E' . $i, 'slow'); + $logger->flush(); + $state = $logger->state; + } + + $next = signozLogger(); + $next->state = $state; + $next->trackError('LATER', 'x'); + $next->flush(); + + expect($state)->toHaveKey(Logger::CB_OPEN_KEY) + ->and($next->requests)->toBeEmpty(); + }); + + it('does not retry other errors', function () { + $logger = signozLogger()->respondToEvents('', 500); + + $logger->trackError('A', 'a'); + $logger->flush(); + + expect($logger->sentEvents())->toHaveCount(1) + ->and($logger->sleeps)->toBe(0); + }); + + it('opens the circuit after consecutive failures and stops sending', function () { + $logger = signozLogger() + ->respondToEvents('', 500) + ->respondToEvents('', 500) + ->respondToEvents('', 500); + + foreach (range(1, 4) as $i) { + $logger->trackError('E' . $i, 'boom'); + } + $logger->flush(); + + expect($logger->sentEvents())->toHaveCount(3) + ->and($logger->state)->toHaveKey(Logger::CB_OPEN_KEY) + ->and($logger->state)->not->toHaveKey(Logger::HEALTH_OK_KEY); + }); + + it('closes the circuit after a success', function () { + $logger = signozLogger()->respondToEvents('', 500)->respondToEvents('{}', 200); + + $logger->trackError('A', 'a'); + $logger->trackError('B', 'b'); + $logger->flush(); + + expect($logger->state)->not->toHaveKey(Logger::CB_FAILURES_KEY); + }); + + it('never throws when the transport blows up', function () { + $logger = new class extends FakeSignozLogger { + protected function sendHttpRequest($method, $url, array $headers, $body, $timeout) + { + throw new RuntimeException('boom'); + } + }; + + $logger->trackError('A', 'a'); + $logger->flush(); + + expect($logger->getPendingEvents())->toBeEmpty(); + }); +}); diff --git a/tests/Unit/TransactionVerifierTest.php b/tests/Unit/TransactionVerifierTest.php new file mode 100644 index 0000000..269477e --- /dev/null +++ b/tests/Unit/TransactionVerifierTest.php @@ -0,0 +1,105 @@ + 4975363, + 'tx_ref' => 'PS_42_1700000000_abcd1234', + 'status' => 'successful', + 'amount' => 50.00, + 'currency' => 'NGN', + ], $overrides); +} + +describe('references', function () { + it('extracts the cart id', function () { + expect(Verifier::cartIdFromReference('PS_42_1700000000_abcd1234'))->toBe(42); + }); + + it('returns null for references not minted by the module', function (?string $reference) { + expect(Verifier::cartIdFromReference($reference))->toBeNull(); + })->with([ + 'empty' => '', + 'null' => null, + 'other prefix' => 'WC_42_1700000000', + 'no separator' => 'PS_42', + 'cart zero' => 'PS_0_1700000000', + 'non numeric cart' => 'PS_abc_1700000000', + 'prefix not at start' => 'x_PS_42_1700000000', + ]); + + it('binds a reference to its own cart only', function () { + expect(Verifier::referenceBelongsToCart('PS_42_1700000000_abcd1234', 42))->toBeTrue() + ->and(Verifier::referenceBelongsToCart('PS_42_1700000000_abcd1234', 43))->toBeFalse() + ->and(Verifier::referenceBelongsToCart('PS_421_1700000000_abcd1234', 42))->toBeFalse() + ->and(Verifier::referenceBelongsToCart('PS_4_1700000000_abcd1234', 42))->toBeFalse() + ->and(Verifier::referenceBelongsToCart('', 0))->toBeFalse(); + }); + + it('accepts the transaction that was requeried', function () { + Verifier::assertReference(verifiedTransaction(), 'PS_42_1700000000_abcd1234'); + })->throwsNoExceptions(); + + it('rejects a transaction for a different reference', function (array $transaction, string $reference) { + Verifier::assertReference($transaction, $reference); + })->with([ + 'other cart' => [verifiedTransaction(), 'PS_43_1700000000_abcd1234'], + 'missing tx_ref' => [verifiedTransaction(['tx_ref' => null]), 'PS_42_1700000000_abcd1234'], + 'empty reference' => [verifiedTransaction(['tx_ref' => '']), ''], + ])->throws(Exception::class, 'Payment reference mismatch'); +}); + +describe('statuses', function () { + it('groups Flutterwave statuses', function (string $status, bool $successful, bool $pending, bool $failed) { + expect(Verifier::isSuccessful($status))->toBe($successful) + ->and(Verifier::isPending($status))->toBe($pending) + ->and(Verifier::isFailed($status))->toBe($failed); + })->with([ + ['successful', true, false, false], + ['success', true, false, false], + ['completed', true, false, false], + ['pending', false, true, false], + ['processing', false, true, false], + ['failed', false, false, true], + ['declined', false, false, true], + ['cancelled', false, false, false], + ['Successful', false, false, false], + ]); + + it('does not treat a missing status as successful', function () { + expect(Verifier::isSuccessful(null))->toBeFalse(); + }); +}); + +describe('payment amount and currency', function () { + it('returns the transaction id when the payment matches', function () { + expect(Verifier::assertPaymentMatches(verifiedTransaction(), 50.00, 'NGN'))->toBe('4975363'); + }); + + it('allows rounding within one cent', function () { + expect(Verifier::assertPaymentMatches(verifiedTransaction(['amount' => '50.005']), 50.00, 'NGN'))->toBe('4975363'); + }); + + it('rejects an amount that does not match', function (mixed $amount) { + Verifier::assertPaymentMatches(verifiedTransaction(['amount' => $amount]), 50.00, 'NGN'); + })->with([ + 'underpaid' => 49.00, + 'overpaid' => 50.02, + 'missing' => null, + 'not numeric' => 'fifty', + ])->throws(Exception::class, 'Payment amount mismatch'); + + it('rejects a currency that does not match', function (?string $currency) { + Verifier::assertPaymentMatches(verifiedTransaction(['currency' => $currency]), 50.00, 'NGN'); + })->with([ + 'other currency' => 'USD', + 'missing' => null, + 'lowercase' => 'ngn', + ])->throws(Exception::class, 'Payment currency mismatch'); + + it('rejects a transaction without an id', function () { + Verifier::assertPaymentMatches(verifiedTransaction(['id' => null]), 50.00, 'NGN'); + })->throws(Exception::class, 'Transaction ID missing'); +});