Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/change-review.yml
Original file line number Diff line number Diff line change
@@ -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><!\[CDATA\[\([^]]*\)\]\]><\/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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 55 additions & 48 deletions flutterwavepayment/classes/FlutterwaveApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
5 changes: 5 additions & 0 deletions flutterwavepayment/classes/FlutterwaveRefundService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading