diff --git a/.github/workflows/release-zip.yml b/.github/workflows/release-zip.yml
index 9a83d99..e00cff4 100644
--- a/.github/workflows/release-zip.yml
+++ b/.github/workflows/release-zip.yml
@@ -28,6 +28,11 @@ jobs:
-x "node_modules/*" \
-x "vendor/*" \
-x "var/*" \
+ -x "Test/*" \
+ -x "build/*" \
+ -x ".phpunit.cache/*" \
+ -x "phpunit.xml.dist" \
+ -x "composer.lock" \
-x "*.DS_Store"
echo "ARCHIVE_NAME=$ARCHIVE_NAME" >> "$GITHUB_ENV"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f57fccc
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,96 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ MIN_COVERAGE: 90
+ # Unit tests mock Magento, so the PHP extensions a full Magento install needs are not required.
+ COMPOSER_OPTIONS: --ignore-platform-req=ext-* --no-scripts
+
+jobs:
+ component:
+ name: ${{ matrix.component }} (coverage >= 90%)
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ component: [Block, Controller, Gateway, Model]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ coverage: pcov
+ tools: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: highest
+ composer-options: ${{ env.COMPOSER_OPTIONS }}
+
+ - name: Run ${{ matrix.component }} tests
+ run: vendor/bin/phpunit --testsuite "${{ matrix.component }}" --coverage-clover "build/coverage/${{ matrix.component }}.xml"
+
+ - name: Enforce ${{ env.MIN_COVERAGE }}% line coverage
+ shell: bash
+ run: |
+ set -o pipefail
+ {
+ echo "### ${{ matrix.component }} coverage"
+ echo '```'
+ php Test/coverage-check.php "build/coverage/${{ matrix.component }}.xml" "$MIN_COVERAGE" "${{ matrix.component }}"
+ echo '```'
+ } | tee -a "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-${{ matrix.component }}
+ path: build/coverage/${{ matrix.component }}.xml
+ if-no-files-found: ignore
+
+ compatibility:
+ name: PHP ${{ matrix.php }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # Each PHP version resolves to the newest Magento framework release that supports it.
+ php: ['8.1', '8.2', '8.3', '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: composer:v2
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v3
+ with:
+ dependency-versions: highest
+ composer-options: ${{ env.COMPOSER_OPTIONS }}
+
+ - name: Run full test suite
+ run: vendor/bin/phpunit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d2db1b4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/vendor/
+/composer.lock
+/build/
+/.phpunit.cache/
+.DS_Store
diff --git a/Controller/Debug/Check.php b/Controller/Debug/Check.php
index 1206f04..57357b3 100644
--- a/Controller/Debug/Check.php
+++ b/Controller/Debug/Check.php
@@ -3,34 +3,38 @@
use Magento\Payment\Helper\Data as PaymentHelper;
use Magento\Framework\App\ActionInterface;
+use Magento\Framework\Controller\Result\RawFactory;
use Magento\Framework\Controller\ResultInterface;
class Check implements ActionInterface
{
protected $paymentHelper;
+ protected $resultRawFactory;
public function __construct(
- PaymentHelper $paymentHelper
+ PaymentHelper $paymentHelper,
+ RawFactory $resultRawFactory
) {
$this->paymentHelper = $paymentHelper;
+ $this->resultRawFactory = $resultRawFactory;
}
- public function execute()
+ public function execute(): ResultInterface
{
try {
$paymentMethod = $this->paymentHelper->getMethodInstance('flutterwave');
- echo "
Flutterwave Payment Method Debug
";
- echo "";
- echo "Is Active: " . ($paymentMethod->isActive() ? 'Yes' : 'No') . "\n";
- echo "Can Use Checkout: " . ($paymentMethod->canUseCheckout() ? 'Yes' : 'No') . "\n";
- echo "Is Available: " . ($paymentMethod->isAvailable() ? 'Yes' : 'No') . "\n";
- echo "
";
+ $output = "Flutterwave Payment Method Debug
";
+ $output .= "";
+ $output .= "Is Active: " . ($paymentMethod->isActive() ? 'Yes' : 'No') . "\n";
+ $output .= "Can Use Checkout: " . ($paymentMethod->canUseCheckout() ? 'Yes' : 'No') . "\n";
+ $output .= "Is Available: " . ($paymentMethod->isAvailable() ? 'Yes' : 'No') . "\n";
+ $output .= "
";
} catch (\Exception $e) {
- echo "Error
";
- echo "An error occurred: " . $e->getMessage();
+ $output = "Error
";
+ $output .= "An error occurred: " . htmlspecialchars($e->getMessage(), ENT_QUOTES);
}
- exit;
+ return $this->resultRawFactory->create()->setContents($output);
}
}
diff --git a/Controller/Payment/Callback.php b/Controller/Payment/Callback.php
index f37ff75..8b3a723 100644
--- a/Controller/Payment/Callback.php
+++ b/Controller/Payment/Callback.php
@@ -71,29 +71,58 @@ public function execute(): ResultInterface
$data = $this->request->getParams();
$this->logger->info('Flutterwave Redirect Callback Data: ' . json_encode($data));
- $reference = $data['client_reference'] ?? null;
- $status = $data['status'] ?? null;
- $this->signozLogger->trackRequestSent('callback', (string) ($reference ?? 'unknown'), '/flutterwave/payment/callback', ['status' => $status ?? 'unknown']);
+ $clientReference = isset($data['client_reference']) ? (string) $data['client_reference'] : '';
+ $transactionId = isset($data['transaction_id']) ? (string) $data['transaction_id'] : '';
+ $this->signozLogger->trackRequestSent('callback', $clientReference !== '' ? $clientReference : 'unknown', '/flutterwave/payment/callback', ['status' => (string) ($data['status'] ?? 'unknown')]);
$redirect = $this->resultRedirectFactory->create();
- if (!$reference || !$status) {
- $this->logger->error('Flutterwave Redirect: Missing reference or status.');
- $this->signozLogger->trackError('callback.invalid', 'Missing reference or status.', '', null, ['payload' => $data]);
+ // Query parameters are attacker-controlled, so without a verifiable transaction nothing is changed.
+ // Cancelled or abandoned payments leave the order pending so the customer can retry.
+ if ($transactionId === '') {
+ $this->messageManager->addErrorMessage(__('Your payment was not completed. Please try again.'));
+ return $redirect->setPath('checkout/cart');
}
try {
- $order = $this->orderFactory->create()->loadByIncrementId($reference);
+ $verification = $this->payment->verifyTransaction($transactionId);
+ $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification));
+
+ if (empty($verification)) {
+ throw new \Exception("Transaction {$transactionId} could not be verified.");
+ }
+
+ $reference = $verification['tx_ref'];
+ $orderIncrementId = $this->payment->getOrderIncrementIdFromTxRef($reference);
+
+ if ($orderIncrementId === null || ($clientReference !== '' && $clientReference !== $orderIncrementId)) {
+ throw new \Exception("Verified transaction {$transactionId} does not belong to order {$clientReference}.");
+ }
+
+ $order = $this->orderFactory->create()->loadByIncrementId($orderIncrementId);
if (!$order || !$order->getId()) {
throw new \Exception("Order not found for reference: $reference");
}
- $verification = $this->payment->verifyTransaction($data['tx_ref']);
- $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification));
+ if (in_array($order->getState(), [Order::STATE_PROCESSING, Order::STATE_COMPLETE], true)) {
+ return $redirect->setPath('checkout/onepage/success');
+ }
+
+ if (!in_array($order->getState(), [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT], true)) {
+ return $redirect->setPath('checkout/cart');
+ }
+
+ if ($verification['status'] !== Payment::STATUS_SUCCESSFUL) {
+ $this->messageManager->addErrorMessage(__('Your payment could not be verified. Please try again.'));
+ return $redirect->setPath('checkout/cart');
+ }
+
+ $order->getPayment()
+ ->setLastTransId($verification['id'])
+ ->setAdditionalInformation('flutterwave_transaction_id', $verification['id'])
+ ->setAdditionalInformation('flutterwave_tx_ref', $reference);
if (
- $status === 'success' &&
- $verification['status'] === 'success' &&
$this->amounts_equal($order->getGrandTotal(), $verification['amount']) &&
$order->getOrderCurrencyCode() === $verification['currency']
) {
@@ -103,7 +132,7 @@ public function execute(): ResultInterface
(float) $verification['amount'],
'callback',
0.0,
- ['order_id' => (string) $order->getIncrementId(), 'status' => $status]
+ ['order_id' => (string) $order->getIncrementId(), 'status' => $verification['status']]
);
$order->setState(Order::STATE_PROCESSING)
@@ -111,31 +140,19 @@ public function execute(): ResultInterface
$order->save();
return $redirect->setPath('checkout/onepage/success');
- } elseif ($status === 'cancelled' &&
- $verification['status'] === 'pending'
- ){
- $order->setState(Order::STATE_CANCELED)
- ->setStatus(Order::STATE_CANCELED);
- $order->save();
- return $redirect->setPath('checkout/cart');
- } elseif( !$this->amounts_equal($order->getGrandTotal(), $verification['amount'] ) || $order->getOrderCurrencyCode() !== $verification['currency'] ){
- $order->setState(Order::STATE_HOLDED)
- ->setStatus(Order::STATE_HOLDED)
- ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount']));
- return $redirect->setPath('checkout/cart');
- } else {
- $order->setState(Order::STATE_CANCELED)
- ->setStatus(Order::STATE_CANCELED)
- ->addCommentToStatusHistory(__('Payment Status could not be verified.'));
- $order->save();
-
- $this->messageManager->addErrorMessage(__('Your payment could not be verified. Please try again.'));
- return $redirect->setPath('checkout/cart');
}
+ $order->setState(Order::STATE_HOLDED)
+ ->setStatus(Order::STATE_HOLDED)
+ ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount']));
+ $order->save();
+
+ $this->messageManager->addErrorMessage(__('Your payment amount did not match the order total. Please contact support.'));
+ return $redirect->setPath('checkout/cart');
+
} catch (\Exception $e) {
$this->logger->error('Flutterwave Redirect Error: ' . $e->getMessage());
- $this->signozLogger->trackError('callback.exception', $e->getMessage(), (string) ($reference ?? ''), $e->getTraceAsString(), ['source' => 'callback']);
+ $this->signozLogger->trackError('callback.exception', $e->getMessage(), $clientReference, $e->getTraceAsString(), ['source' => 'callback']);
$this->messageManager->addErrorMessage(__('A server error occurred. Please contact support.'));
return $redirect->setPath('checkout/cart');
}
diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php
index 59259df..a09c8e9 100644
--- a/Controller/Payment/Webhook.php
+++ b/Controller/Payment/Webhook.php
@@ -5,10 +5,9 @@
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
+use Magento\Framework\App\Request\Http as HttpRequest;
use Flutterwave\Payment\Model\Payment;
use Magento\Sales\Model\Order;
-use Magento\Framework\Message\ManagerInterface;
-use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Sales\Model\OrderFactory;
use Psr\Log\LoggerInterface;
@@ -20,14 +19,14 @@ class Webhook implements HttpPostActionInterface, CsrfAwareActionInterface
private JsonFactory $jsonFactory;
private OrderFactory $orderFactory;
private $payment;
- private $messageManager;
+ private HttpRequest $request;
private FlutterwaveSignozLogger $signozLogger;
public function __construct(
LoggerInterface $logger,
Payment $payment,
JsonFactory $jsonFactory,
- ManagerInterface $messageManager,
+ HttpRequest $request,
OrderFactory $orderFactory,
FlutterwaveSignozLogger $signozLogger
) {
@@ -35,41 +34,10 @@ public function __construct(
$this->payment = $payment;
$this->jsonFactory = $jsonFactory;
$this->orderFactory = $orderFactory;
- $this->messageManager = $messageManager;
+ $this->request = $request;
$this->signozLogger = $signozLogger;
}
- /**
- * Get the Ip of the current request.
- *
- * @return string
- */
- public function getFlutterwaveClientIp() {
- $ip_keys = array(
- 'HTTP_CLIENT_IP',
- 'HTTP_X_FORWARDED_FOR',
- 'HTTP_X_FORWARDED',
- 'HTTP_X_CLUSTER_CLIENT_IP',
- 'HTTP_FORWARDED_FOR',
- 'HTTP_FORWARDED',
- 'REMOTE_ADDR',
- );
-
- foreach ( $ip_keys as $key ) {
- if ( ! empty( $_SERVER[ $key ] ) ) {
- $ip_list = explode( ',', $_SERVER[ $key ] );
- foreach ( $ip_list as $ip ) {
- $ip = trim( $ip );
- if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
- return $ip;
- }
- }
- }
- }
-
- return 'UNKNOWN';
- }
-
/**
* Check Amount Equals.
*
@@ -93,107 +61,94 @@ public function execute()
$resultJson = $this->jsonFactory->create();
try {
+ $secretHash = $this->payment->getSecretHash();
+ $signature = (string) $this->request->getHeader('verif-hash');
+
+ if ($secretHash === '' || $signature === '' || !hash_equals($secretHash, $signature)) {
+ $this->logger->warning('Flutterwave Webhook rejected: invalid or missing verif-hash.');
+ $this->signozLogger->trackError('webhook.unauthorized', 'Invalid or missing verif-hash header.', '', null, ['configured' => $secretHash !== '']);
+ return $resultJson->setData(['success' => false, 'message' => 'Unauthorized'])->setHttpResponseCode(401);
+ }
- // if ( '52.3.180.49' !== $this->getFlutterwaveClientIp() ) {
- // $this->logger->info( 'Faudulent Webhook Notification Attempt [Access Restricted]: ' . (string) $this->getFlutterwaveClientIp() );
- // return $resultJson->setData(
- // array(
- // 'status' => 'error',
- // 'message' => 'Unauthorized Access (Restriction)',
- // )
- // )->setHttpResponseCode(401);
- // }
-
- $payload = file_get_contents('php://input');
+ $payload = (string) $this->request->getContent();
$this->logger->info('Flutterwave Webhook Payload: ' . $payload);
$data = json_decode($payload, true);
+ $transactionId = isset($data['data']['id']) ? (string) $data['data']['id'] : '';
+
+ if ($transactionId === '' || !ctype_digit($transactionId)) {
+ $this->signozLogger->trackError('webhook.invalid_payload', 'Invalid webhook payload: Missing transaction id.', '', null, ['payload' => $payload]);
+ return $resultJson->setData(['success' => false, 'message' => 'Invalid webhook payload: Missing transaction id.'])->setHttpResponseCode(400);
+ }
- if (empty($data['data']['reference'])) {
- $this->signozLogger->trackError('webhook.invalid_payload', 'Invalid webhook payload: Missing reference.', '', null, ['payload' => $payload]);
- throw new \Exception('Invalid webhook payload: Missing reference.');
+ // Only the verified transaction is trusted; the payload status and tx_ref are ignored.
+ $verification = $this->payment->verifyTransaction($transactionId);
+ $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification));
+
+ if (empty($verification)) {
+ $this->signozLogger->trackError('webhook.verify_failed', 'Transaction could not be verified.', $transactionId, null, []);
+ return $resultJson->setData(['success' => false, 'message' => 'Transaction could not be verified.'])->setHttpResponseCode(502);
}
- $reference = $data['data']['reference'];
- $status = $data['data']['status'];
- $this->signozLogger->trackRequestSent('webhook', $reference, '/flutterwave/payment/webhook', ['status' => $status]);
- $parts = explode('_', $reference);
+ $reference = $verification['tx_ref'];
+ $this->signozLogger->trackRequestSent('webhook', $reference, '/flutterwave/payment/webhook', ['status' => $verification['status']]);
- if (count($parts) < 2 || !is_numeric($parts[1])) {
- throw new \Exception('Invalid reference format: ' . $reference);
+ $orderIncrementId = $this->payment->getOrderIncrementIdFromTxRef($reference);
+ if ($orderIncrementId === null) {
+ // Not a transaction created by this module; acknowledge so Flutterwave stops retrying.
+ return $resultJson->setData(['success' => true, 'message' => 'Transaction not handled by this store.'])->setHttpResponseCode(200);
}
- $orderIncrementId = $parts[1];
$order = $this->orderFactory->create()->loadByIncrementId($orderIncrementId);
if (!$order || !$order->getId()) {
throw new \Exception("Order with Increment ID {$orderIncrementId} not found.");
}
- if(in_array($order->getState(), [ Order::STATE_PROCESSING, Order::STATE_COMPLETE ])) {
- return $resultJson->setData(['success' => true, 'message' => 'Order Already Processed Successfully'])->setHttpResponseCode(400);
+ if (!in_array($order->getState(), [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT], true)) {
+ return $resultJson->setData(['success' => true, 'message' => 'Order Already Processed'])->setHttpResponseCode(200);
+ }
+
+ if ($verification['status'] !== Payment::STATUS_SUCCESSFUL) {
+ // Failed or pending attempts leave the order open so the customer can retry.
+ return $resultJson->setData(['success' => true, 'message' => 'Payment not successful; order left unchanged.'])->setHttpResponseCode(200);
}
- //TODO: get the current order status and make sure it is not completed. if so return a response stating it has been processed.
- try {
- $verification = $this->payment->verifyTransaction($data['reference']);
- $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification));
-
- if (
- $status === 'success' &&
- $verification['status'] === 'success' &&
- $this->amounts_equal($order->getGrandTotal(), $verification['amount']) &&
- $order->getOrderCurrencyCode() === $verification['currency']
- ) {
- $this->signozLogger->trackTransaction(
- $reference,
- (string) $order->getOrderCurrencyCode(),
- (float) $verification['amount'],
- 'webhook',
- 0.0,
- ['order_id' => (string) $order->getIncrementId(), 'status' => $status]
- );
-
- $order->setState(Order::STATE_PROCESSING)
- ->setStatus(Order::STATE_PROCESSING);
- $order->save();
-
- return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201);
- } elseif ($status === 'cancelled' &&
- $verification['status'] === 'pending'
- ){
- $order->setState(Order::STATE_CANCELED)
- ->setStatus(Order::STATE_CANCELED);
- $order->save();
- return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201);
- } elseif( !$this->amounts_equal($order->getGrandTotal(), $verification['amount'] ) || $order->getOrderCurrencyCode() !== $verification['currency'] ){
- $order->setState(Order::STATE_HOLDED)
- ->setStatus(Order::STATE_HOLDED)
- ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount']));
- return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201);
- } else {
- $order->setState(Order::STATE_CANCELED)
- ->setStatus(Order::STATE_CANCELED)
- ->addCommentToStatusHistory(__('Payment Status could not be verified. Please try resending the webhook for this transaction.'));
- $order->save();
-
- $this->messageManager->addErrorMessage(__('Your payment could not be verified. Please try again.'));
- $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201);
- }
-
- } catch (\Exception $e) {
- $this->logger->error('Flutterwave Redirect Error: ' . $e->getMessage());
- $this->signozLogger->trackError('webhook.verify_failed', $e->getMessage(), $reference ?? '', $e->getTraceAsString(), ['status' => $status ?? 'unknown']);
- $this->messageManager->addErrorMessage(__('A server error occurred. Please contact support.'));
- return $resultJson->setData(['success' => false, 'message' => $e->getMessage()])->setHttpResponseCode(500);
+ $order->getPayment()
+ ->setLastTransId($verification['id'])
+ ->setAdditionalInformation('flutterwave_transaction_id', $verification['id'])
+ ->setAdditionalInformation('flutterwave_tx_ref', $reference);
+
+ if (
+ $this->amounts_equal($order->getGrandTotal(), $verification['amount']) &&
+ $order->getOrderCurrencyCode() === $verification['currency']
+ ) {
+ $this->signozLogger->trackTransaction(
+ $reference,
+ (string) $order->getOrderCurrencyCode(),
+ (float) $verification['amount'],
+ 'webhook',
+ 0.0,
+ ['order_id' => (string) $order->getIncrementId(), 'status' => $verification['status']]
+ );
+
+ $order->setState(Order::STATE_PROCESSING)
+ ->setStatus(Order::STATE_PROCESSING);
+ $order->save();
+
+ return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(200);
}
+ $order->setState(Order::STATE_HOLDED)
+ ->setStatus(Order::STATE_HOLDED)
+ ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount']));
+ $order->save();
- return $resultJson->setData(['success' => true, 'message' => 'Webhook for Order' . $orderIncrementId . ' received and processed'])->setHttpResponseCode(201);
+ return $resultJson->setData(['success' => true, 'message' => 'Order placed on hold'])->setHttpResponseCode(200);
} catch (\Exception $e) {
$this->logger->critical('Flutterwave Webhook Error: ' . $e->getMessage());
$this->signozLogger->trackError('webhook.exception', $e->getMessage(), '', $e->getTraceAsString(), ['source' => 'webhook']);
- return $resultJson->setData(['success' => false, 'message' => $e->getMessage()])->setHttpResponseCode(500);
+ return $resultJson->setData(['success' => false, 'message' => 'Webhook processing failed'])->setHttpResponseCode(500);
}
}
diff --git a/Gateway/Command/VerifyCommand.php b/Gateway/Command/VerifyCommand.php
index c80af8c..acc9b31 100644
--- a/Gateway/Command/VerifyCommand.php
+++ b/Gateway/Command/VerifyCommand.php
@@ -25,7 +25,7 @@ public function execute(array $commandSubject)
$response = $this->apiClient->verifyTransaction($transactionId);
- if ($response['status'] !== 'successful') {
+ if (($response['status'] ?? null) !== 'successful') {
throw new \Exception('Payment verification failed');
}
diff --git a/Model/Api/Client.php b/Model/Api/Client.php
index e80fecd..ff78aff 100644
--- a/Model/Api/Client.php
+++ b/Model/Api/Client.php
@@ -4,6 +4,7 @@
use Magento\Framework\HTTP\Client\Curl;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\UrlInterface;
+use Magento\Store\Model\ScopeInterface;
class Client {
protected $curl;
@@ -44,8 +45,9 @@ public function createPaymentLink(array $payload = []) {
$this->curl->post('https://api.flutterwave.com/v3/payments', json_encode($requestData));
$response = json_decode($this->curl->getBody(), true);
+ // Callers read $response['link'], so return the response data rather than the bare link.
if (isset($response['data']['link'])) {
- return $response['data']['link'];
+ return $response['data'];
}
return false;
}
diff --git a/Model/Payment.php b/Model/Payment.php
index 04648b8..d71ecdb 100644
--- a/Model/Payment.php
+++ b/Model/Payment.php
@@ -7,10 +7,14 @@
use Magento\Framework\HTTP\Client\Curl;
use Magento\Framework\UrlInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
+use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Store\Model\ScopeInterface;
class Payment extends AbstractMethod
{
+ public const TX_REF_PREFIX = 'MAG';
+ public const STATUS_SUCCESSFUL = 'successful';
+
protected $_code = 'flutterwave';
protected $_isOffline = false;
protected $checkoutSession;
@@ -18,19 +22,40 @@ class Payment extends AbstractMethod
protected $curl;
protected $urlBuilder;
protected $scopeConfig;
+ protected $encryptor;
public function __construct(
Session $checkoutSession,
Order $order,
Curl $curl,
UrlInterface $urlBuilder,
- ScopeConfigInterface $scopeConfig
+ ScopeConfigInterface $scopeConfig,
+ EncryptorInterface $encryptor
) {
$this->checkoutSession = $checkoutSession;
$this->order = $order;
$this->curl = $curl;
$this->urlBuilder = $urlBuilder;
$this->scopeConfig = $scopeConfig;
+ $this->encryptor = $encryptor;
+ }
+
+ public function getSecretHash(): string
+ {
+ $value = (string) $this->scopeConfig->getValue('payment/flutterwave/secret_hash', ScopeInterface::SCOPE_STORE);
+ return $value === '' ? '' : (string) $this->encryptor->decrypt($value);
+ }
+
+ /**
+ * Extract the order increment id from a tx_ref built by startTransaction (MAG__).
+ */
+ public function getOrderIncrementIdFromTxRef(string $txRef): ?string
+ {
+ $parts = explode('_', $txRef);
+ if (count($parts) !== 3 || $parts[0] !== self::TX_REF_PREFIX || !ctype_digit($parts[1])) {
+ return null;
+ }
+ return $parts[1];
}
public function startTransaction()
@@ -46,10 +71,10 @@ public function startTransaction()
$requestData = [
'amount' => $amount,
- 'email' => $email,
'currency' => $currency,
- 'reference' => "MAG_".$order->getIncrementId()."_". uniqid('old'),
- 'callback' => $callbackUrl
+ 'tx_ref' => self::TX_REF_PREFIX . "_" . $orderId . "_" . uniqid('old'),
+ 'redirect_url' => $callbackUrl,
+ 'customer' => ['email' => $email]
];
$this->curl->setHeaders([
@@ -66,7 +91,14 @@ public function startTransaction()
return false;
}
- public function verifyTransaction(string $reference) {
+ /**
+ * Verify a transaction by its Flutterwave transaction id (numeric), not by tx_ref.
+ */
+ public function verifyTransaction(string $transactionId) {
+ if (!ctype_digit($transactionId)) {
+ return [];
+ }
+
$apiKey = $this->scopeConfig->getValue('payment/flutterwave/api_key', ScopeInterface::SCOPE_STORE);
$this->curl->setHeaders([
@@ -74,14 +106,16 @@ public function verifyTransaction(string $reference) {
'Content-Type' => 'application/json'
]);
- $this->curl->get('https://api.flutterwave.com/v3/transactions/' . $reference . '/verify');
+ $this->curl->get('https://api.flutterwave.com/v3/transactions/' . $transactionId . '/verify');
$response = json_decode($this->curl->getBody(), true);
- if( isset( $response[ 'data' ] ) ) {
+ if (isset($response['data']['id'], $response['data']['tx_ref'], $response['data']['status'], $response['data']['amount'], $response['data']['currency'])) {
return [
+ 'id' => (string) $response['data']['id'],
+ 'tx_ref' => (string) $response['data']['tx_ref'],
'amount' => $response['data']['amount'],
- 'currency' => $response['data']['currency'],
- 'status' => $response['data']['status'],
+ 'currency' => (string) $response['data']['currency'],
+ 'status' => (string) $response['data']['status'],
];
}
diff --git a/README.md b/README.md
index e119ee3..bfacc27 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ Available settings:
- `Enable Flutterwave Payment`: Enable/disable the payment method
- `Title`: The title shown to customers at checkout
- `API Secret Key`: Secret API key used to sign requests
+- `Webhook Secret Hash`: Must match the Secret Hash configured for webhooks in the Flutterwave dashboard. Webhooks without a matching `verif-hash` header are rejected, and all webhooks are rejected while this is empty.
- `API Public Key`: Public API key / identifier
- `Sort Order`: Display order among payment methods
- `Webhook URL`: A label/readonly field that shows the webhook endpoint you should register in the Flutterwave dashboard
@@ -67,7 +68,7 @@ Register the webhook endpoint at your Flutterwave merchant dashboard. The webhoo
/flutterwave/payment/webhook
```
-Replace `` with your store's base URL (for example `https://store.example.com/flutterwave/payment/webhook`). The module exposes controllers to handle webhook notifications and payment callbacks.
+Replace `` with your store's base URL (for example `https://store.example.com/flutterwave/payment/webhook`). Set a Secret Hash on the same dashboard page and enter the same value in the module's `Webhook Secret Hash` setting. The module exposes controllers to handle webhook notifications and payment callbacks.
## Usage
@@ -94,10 +95,22 @@ Important code locations:
- To customize request/response handling, modify `Model/Api/Client.php` and `Model/Payment/FlutterwaveAdapter.php`.
- To change checkout behaviors or UI, edit the KnockoutJS view models in `view/frontend/web/js/view/payment/` and the template `view/frontend/web/template/payment/flutterwave.html`.
-- Add unit/integration tests alongside the relevant classes where needed.
+- Add unit tests under `Test/Unit/` mirroring the class path.
## Testing & debugging
+Unit tests live in `Test/Unit` and run without a Magento installation; Magento packages come from the public [Mage-OS mirror](https://mirror.mage-os.org/).
+
+```bash
+composer update --ignore-platform-req='ext-*' --no-scripts
+vendor/bin/phpunit # all tests
+vendor/bin/phpunit --testsuite Controller # one component: Block, Controller, Gateway or Model
+vendor/bin/phpunit --testsuite Controller --coverage-clover build/coverage/Controller.xml
+php Test/coverage-check.php build/coverage/Controller.xml 90 Controller # fails below 90% line coverage
+```
+
+Coverage needs pcov or Xdebug. The `Tests` GitHub workflow runs each component separately and fails if its line coverage drops below 90%, and runs the full suite on PHP 8.1–8.4.
+
- Enable developer mode in Magento to see error output: `php bin/magento deploy:mode:set developer`.
- Check `var/log/` and `var/report` for exceptions and debug messages.
- Use `bin/magento setup:di:compile` after changing PHP classes.
diff --git a/Test/Unit/Block/Adminhtml/WebhookUrlTest.php b/Test/Unit/Block/Adminhtml/WebhookUrlTest.php
new file mode 100644
index 0000000..751dde0
--- /dev/null
+++ b/Test/Unit/Block/Adminhtml/WebhookUrlTest.php
@@ -0,0 +1,31 @@
+createMock(UrlInterface::class);
+ $urlBuilder->expects($this->once())->method('getUrl')->with(
+ 'flutterwave/payment/webhook',
+ ['_scope' => 0, '_nosid' => true, '_direct' => 'flutterwave/payment/webhook']
+ )->willReturn('https://shop.test/flutterwave/payment/webhook');
+
+ $objectManager = new ObjectManager($this);
+ $objectManager->prepareObjectManager();
+ $block = $objectManager->getObject(WebhookUrl::class, ['urlBuilder' => $urlBuilder]);
+
+ $method = new \ReflectionMethod($block, '_getElementHtml');
+ $html = $method->invoke($block, $this->createMock(AbstractElement::class));
+
+ $this->assertStringContainsString('https://shop.test/flutterwave/payment/webhook', $html);
+ }
+}
diff --git a/Test/Unit/Controller/Debug/CheckTest.php b/Test/Unit/Controller/Debug/CheckTest.php
new file mode 100644
index 0000000..dc407b5
--- /dev/null
+++ b/Test/Unit/Controller/Debug/CheckTest.php
@@ -0,0 +1,57 @@
+paymentHelper = $this->createMock(PaymentHelper::class);
+ $this->raw = $this->createMock(Raw::class);
+ $this->raw->method('setContents')->willReturnCallback(function (string $contents) {
+ $this->contents = $contents;
+ return $this->raw;
+ });
+ $rawFactory = $this->createMock(RawFactory::class);
+ $rawFactory->method('create')->willReturn($this->raw);
+
+ $this->controller = new Check($this->paymentHelper, $rawFactory);
+ }
+
+ public function testReportsPaymentMethodAvailability(): void
+ {
+ $method = $this->createMock(MethodInterface::class);
+ $method->method('isActive')->willReturn(true);
+ $method->method('canUseCheckout')->willReturn(false);
+ $method->method('isAvailable')->willReturn(true);
+ $this->paymentHelper->method('getMethodInstance')->with('flutterwave')->willReturn($method);
+
+ $this->assertSame($this->raw, $this->controller->execute());
+ $this->assertStringContainsString("Is Active: Yes\n", $this->contents);
+ $this->assertStringContainsString("Can Use Checkout: No\n", $this->contents);
+ $this->assertStringContainsString("Is Available: Yes\n", $this->contents);
+ }
+
+ public function testReportsErrorsEscaped(): void
+ {
+ $this->paymentHelper->method('getMethodInstance')->willThrowException(new \Exception('missing'));
+
+ $this->controller->execute();
+ $this->assertStringContainsString('Error
', $this->contents);
+ $this->assertStringContainsString('<b>missing</b>', $this->contents);
+ }
+}
diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php
new file mode 100644
index 0000000..0fdd428
--- /dev/null
+++ b/Test/Unit/Controller/Payment/CallbackTest.php
@@ -0,0 +1,288 @@
+payment = $this->createMock(Payment::class);
+ $this->payment->method('getOrderIncrementIdFromTxRef')->willReturnCallback(
+ static fn (string $txRef) => $txRef === self::TX_REF ? '000000055' : null
+ );
+
+ $this->request = $this->createMock(RequestInterface::class);
+ $this->messageManager = $this->createMock(ManagerInterface::class);
+ $this->signozLogger = $this->createMock(FlutterwaveSignozLogger::class);
+
+ $this->redirect = $this->createMock(Redirect::class);
+ $this->redirect->method('setPath')->willReturnCallback(function (string $path) {
+ $this->redirectPath = $path;
+ return $this->redirect;
+ });
+ $redirectFactory = $this->createMock(RedirectFactory::class);
+ $redirectFactory->method('create')->willReturn($this->redirect);
+
+ $this->orderPayment = $this->createMock(OrderPayment::class);
+ $this->orderPayment->method('setLastTransId')->willReturnSelf();
+ $this->orderPayment->method('setAdditionalInformation')->willReturnSelf();
+
+ $this->order = $this->createMock(Order::class);
+ $this->order->method('getId')->willReturn(55);
+ $this->order->method('getIncrementId')->willReturn('000000055');
+ $this->order->method('getGrandTotal')->willReturn(100.0);
+ $this->order->method('getOrderCurrencyCode')->willReturn('NGN');
+ $this->order->method('getPayment')->willReturn($this->orderPayment);
+ $this->order->method('setState')->willReturnSelf();
+ $this->order->method('setStatus')->willReturnSelf();
+ $this->order->method('addCommentToStatusHistory')->willReturnSelf();
+ $this->order->method('loadByIncrementId')->willReturnSelf();
+
+ $this->orderFactory = $this->createMock(OrderFactory::class);
+ $this->orderFactory->method('create')->willReturn($this->order);
+
+ $this->controller = $this->createController($this->orderFactory);
+ }
+
+ private function createController(OrderFactory $orderFactory): Callback
+ {
+ $redirectFactory = $this->createMock(RedirectFactory::class);
+ $redirectFactory->method('create')->willReturn($this->redirect);
+
+ return new Callback(
+ $orderFactory,
+ $this->createMock(Session::class),
+ $this->payment,
+ $redirectFactory,
+ $this->createMock(JsonFactory::class),
+ $this->createMock(LoggerInterface::class),
+ $this->messageManager,
+ $this->request,
+ $this->signozLogger
+ );
+ }
+
+ private function givenParams(array $params): void
+ {
+ $this->request->method('getParams')->willReturn($params);
+ }
+
+ private function givenVerification(array $overrides = []): void
+ {
+ $this->payment->method('verifyTransaction')->with('999111')->willReturn(array_merge([
+ 'id' => '999111',
+ 'tx_ref' => self::TX_REF,
+ 'amount' => 100,
+ 'currency' => 'NGN',
+ 'status' => 'successful',
+ ], $overrides));
+ }
+
+ private function redirectParams(array $overrides = []): array
+ {
+ return array_merge([
+ 'client_reference' => '000000055',
+ 'status' => 'successful',
+ 'tx_ref' => self::TX_REF,
+ 'transaction_id' => '999111',
+ ], $overrides);
+ }
+
+ public function testMarksOrderProcessingForVerifiedSuccessfulPayment(): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn(Order::STATE_NEW);
+
+ $this->order->expects($this->once())->method('setState')->with(Order::STATE_PROCESSING);
+ $this->order->expects($this->once())->method('save');
+ $this->orderPayment->expects($this->once())->method('setLastTransId')->with('999111');
+ $this->signozLogger->expects($this->once())->method('trackTransaction');
+
+ $this->assertSame($this->redirect, $this->controller->execute());
+ $this->assertSame('checkout/onepage/success', $this->redirectPath);
+ }
+
+ public function testCancelledRedirectWithoutTransactionIdLeavesOrderPending(): void
+ {
+ $this->givenParams(['client_reference' => '000000055', 'status' => 'cancelled', 'tx_ref' => self::TX_REF]);
+ $this->payment->expects($this->never())->method('verifyTransaction');
+ $this->orderFactory->expects($this->never())->method('create');
+ $this->messageManager->expects($this->once())->method('addErrorMessage');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public function testUnverifiableTransactionChangesNothing(): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->payment->method('verifyTransaction')->willReturn([]);
+ $this->order->expects($this->never())->method('save');
+ $this->signozLogger->expects($this->once())->method('trackError')->with('callback.exception');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public function testRejectsTransactionBelongingToAnotherOrder(): void
+ {
+ // Finding 5: attacker's own transaction presented against someone else's order.
+ $this->givenParams($this->redirectParams(['client_reference' => '000000077']));
+ $this->givenVerification();
+ $this->orderFactory->expects($this->never())->method('create');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public function testRejectsTransactionNotCreatedByThisStore(): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->givenVerification(['tx_ref' => 'WOO_1_abc']);
+ $this->orderFactory->expects($this->never())->method('create');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public function testFindsOrderFromVerifiedTxRefWhenClientReferenceAbsent(): void
+ {
+ $this->givenParams($this->redirectParams(['client_reference' => null]));
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT);
+ $this->order->expects($this->once())->method('loadByIncrementId')->with('000000055');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/onepage/success', $this->redirectPath);
+ }
+
+ public function testMissingOrderRedirectsToCart(): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->givenVerification();
+ $missing = $this->createMock(Order::class);
+ $missing->method('loadByIncrementId')->willReturnSelf();
+ $missing->method('getId')->willReturn(null);
+ $orderFactory = $this->createMock(OrderFactory::class);
+ $orderFactory->method('create')->willReturn($missing);
+ $this->messageManager->expects($this->once())->method('addErrorMessage');
+
+ $this->createController($orderFactory)->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public static function paidStateProvider(): array
+ {
+ return [[Order::STATE_PROCESSING], [Order::STATE_COMPLETE]];
+ }
+
+ #[DataProvider('paidStateProvider')]
+ public function testAlreadyPaidOrderGoesToSuccessPage(string $state): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn($state);
+ $this->order->expects($this->never())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/onepage/success', $this->redirectPath);
+ }
+
+ public static function closedStateProvider(): array
+ {
+ return [[Order::STATE_CANCELED], [Order::STATE_HOLDED], [Order::STATE_CLOSED]];
+ }
+
+ #[DataProvider('closedStateProvider')]
+ public function testClosedOrderIsNotReopened(string $state): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn($state);
+ $this->order->expects($this->never())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public static function unsuccessfulStatusProvider(): array
+ {
+ return [['failed'], ['pending'], ['success']];
+ }
+
+ #[DataProvider('unsuccessfulStatusProvider')]
+ public function testUnsuccessfulPaymentNeverCancelsOrder(string $status): void
+ {
+ $this->givenParams($this->redirectParams(['status' => 'successful']));
+ $this->givenVerification(['status' => $status]);
+ $this->order->method('getState')->willReturn(Order::STATE_NEW);
+
+ $this->order->expects($this->never())->method('setState');
+ $this->order->expects($this->never())->method('save');
+ $this->messageManager->expects($this->once())->method('addErrorMessage');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public static function amountMismatchProvider(): array
+ {
+ return [
+ 'amount' => [['amount' => 1]],
+ 'currency' => [['currency' => 'USD']],
+ ];
+ }
+
+ #[DataProvider('amountMismatchProvider')]
+ public function testHoldsAndSavesOrderOnAmountOrCurrencyMismatch(array $overrides): void
+ {
+ $this->givenParams($this->redirectParams());
+ $this->givenVerification($overrides);
+ $this->order->method('getState')->willReturn(Order::STATE_NEW);
+
+ $this->order->expects($this->once())->method('setState')->with(Order::STATE_HOLDED);
+ $this->order->expects($this->once())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame('checkout/cart', $this->redirectPath);
+ }
+
+ public function testAmountsEqual(): void
+ {
+ $this->assertTrue($this->controller->amounts_equal(100, '100.009'));
+ $this->assertFalse($this->controller->amounts_equal(100, 99));
+ }
+}
diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php
new file mode 100644
index 0000000..b2d899f
--- /dev/null
+++ b/Test/Unit/Controller/Payment/WebhookTest.php
@@ -0,0 +1,323 @@
+payment = $this->createMock(Payment::class);
+ $this->payment->method('getSecretHash')->willReturn(self::SECRET);
+ $this->payment->method('getOrderIncrementIdFromTxRef')->willReturnCallback(
+ static fn (string $txRef) => $txRef === self::TX_REF ? '000000055' : null
+ );
+
+ $this->request = $this->createMock(HttpRequest::class);
+ $this->signozLogger = $this->createMock(FlutterwaveSignozLogger::class);
+
+ $this->result = $this->createMock(Json::class);
+ $this->result->method('setData')->willReturnCallback(function (array $data) {
+ $this->responseData = $data;
+ return $this->result;
+ });
+ $this->result->method('setHttpResponseCode')->willReturnCallback(function (int $code) {
+ $this->responseCode = $code;
+ return $this->result;
+ });
+ $jsonFactory = $this->createMock(JsonFactory::class);
+ $jsonFactory->method('create')->willReturn($this->result);
+
+ $this->orderPayment = $this->createMock(OrderPayment::class);
+ $this->orderPayment->method('setLastTransId')->willReturnSelf();
+ $this->orderPayment->method('setAdditionalInformation')->willReturnSelf();
+
+ $this->order = $this->createMock(Order::class);
+ $this->order->method('getId')->willReturn(55);
+ $this->order->method('getIncrementId')->willReturn('000000055');
+ $this->order->method('getGrandTotal')->willReturn(100.0);
+ $this->order->method('getOrderCurrencyCode')->willReturn('NGN');
+ $this->order->method('getPayment')->willReturn($this->orderPayment);
+ $this->order->method('setState')->willReturnSelf();
+ $this->order->method('setStatus')->willReturnSelf();
+ $this->order->method('addCommentToStatusHistory')->willReturnSelf();
+ $this->order->method('loadByIncrementId')->willReturnSelf();
+
+ $this->orderFactory = $this->createMock(OrderFactory::class);
+ $this->orderFactory->method('create')->willReturn($this->order);
+
+ $this->controller = new Webhook(
+ $this->createMock(LoggerInterface::class),
+ $this->payment,
+ $jsonFactory,
+ $this->request,
+ $this->orderFactory,
+ $this->signozLogger
+ );
+ }
+
+ private function givenRequest(?string $signature, array $payload): void
+ {
+ $this->request->method('getHeader')->with('verif-hash')->willReturn($signature ?? false);
+ $this->request->method('getContent')->willReturn(json_encode($payload));
+ }
+
+ private function givenVerification(array $overrides = []): void
+ {
+ $this->payment->method('verifyTransaction')->with('999111')->willReturn(array_merge([
+ 'id' => '999111',
+ 'tx_ref' => self::TX_REF,
+ 'amount' => 100,
+ 'currency' => 'NGN',
+ 'status' => 'successful',
+ ], $overrides));
+ }
+
+ private function chargeCompleted(array $data = []): array
+ {
+ return ['event' => 'charge.completed', 'data' => array_merge(['id' => 999111, 'tx_ref' => self::TX_REF, 'status' => 'successful'], $data)];
+ }
+
+ public static function badSignatureProvider(): array
+ {
+ return [
+ 'missing header' => [null],
+ 'empty header' => [''],
+ 'wrong hash' => ['guess'],
+ ];
+ }
+
+ #[DataProvider('badSignatureProvider')]
+ public function testRejectsRequestsWithoutValidSignature(?string $signature): void
+ {
+ $this->givenRequest($signature, $this->chargeCompleted());
+ $this->payment->expects($this->never())->method('verifyTransaction');
+ $this->order->expects($this->never())->method('save');
+
+ $this->assertSame($this->result, $this->controller->execute());
+ $this->assertSame(401, $this->responseCode);
+ $this->assertFalse($this->responseData['success']);
+ }
+
+ public function testRejectsAllRequestsWhenSecretHashNotConfigured(): void
+ {
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getSecretHash')->willReturn('');
+ $payment->expects($this->never())->method('verifyTransaction');
+ $this->request->method('getHeader')->willReturn('');
+
+ $jsonFactory = $this->createMock(JsonFactory::class);
+ $jsonFactory->method('create')->willReturn($this->result);
+ $controller = new Webhook($this->createMock(LoggerInterface::class), $payment, $jsonFactory, $this->request, $this->orderFactory, $this->signozLogger);
+
+ $controller->execute();
+ $this->assertSame(401, $this->responseCode);
+ }
+
+ public static function invalidPayloadProvider(): array
+ {
+ return [
+ 'not json' => [null],
+ 'missing id' => [['data' => ['tx_ref' => self::TX_REF]]],
+ 'non numeric id' => [['data' => ['id' => '99/../1']]],
+ ];
+ }
+
+ #[DataProvider('invalidPayloadProvider')]
+ public function testRejectsPayloadWithoutNumericTransactionId(?array $payload): void
+ {
+ $this->request->method('getHeader')->willReturn(self::SECRET);
+ $this->request->method('getContent')->willReturn($payload === null ? 'not-json' : json_encode($payload));
+ $this->payment->expects($this->never())->method('verifyTransaction');
+
+ $this->controller->execute();
+ $this->assertSame(400, $this->responseCode);
+ }
+
+ public function testReturnsBadGatewayWhenTransactionCannotBeVerified(): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted());
+ $this->payment->method('verifyTransaction')->willReturn([]);
+ $this->order->expects($this->never())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame(502, $this->responseCode);
+ }
+
+ public function testMarksOrderProcessingForVerifiedSuccessfulPayment(): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted());
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT);
+
+ $this->order->expects($this->once())->method('setState')->with(Order::STATE_PROCESSING);
+ $this->order->expects($this->once())->method('save');
+ $this->orderPayment->expects($this->once())->method('setLastTransId')->with('999111');
+ $this->signozLogger->expects($this->once())->method('trackTransaction');
+
+ $this->controller->execute();
+ $this->assertSame(200, $this->responseCode);
+ $this->assertSame('Order Processed Successfully', $this->responseData['message']);
+ }
+
+ public function testUsesVerifiedTxRefNotPayloadToFindOrder(): void
+ {
+ // Payload claims to be for a different order; only the verified tx_ref counts.
+ $this->givenRequest(self::SECRET, $this->chargeCompleted(['tx_ref' => 'MAG_000000099_x', 'status' => 'failed']));
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn(Order::STATE_NEW);
+
+ $this->payment->expects($this->once())->method('getOrderIncrementIdFromTxRef')->with(self::TX_REF);
+ $this->order->expects($this->once())->method('loadByIncrementId')->with('000000055');
+ $this->order->expects($this->once())->method('setState')->with(Order::STATE_PROCESSING);
+
+ $this->controller->execute();
+ }
+
+ public static function amountMismatchProvider(): array
+ {
+ return [
+ 'amount' => [['amount' => 1]],
+ 'currency' => [['currency' => 'USD']],
+ ];
+ }
+
+ #[DataProvider('amountMismatchProvider')]
+ public function testHoldsAndSavesOrderOnAmountOrCurrencyMismatch(array $overrides): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted());
+ $this->givenVerification($overrides);
+ $this->order->method('getState')->willReturn(Order::STATE_NEW);
+
+ $this->order->expects($this->once())->method('setState')->with(Order::STATE_HOLDED);
+ $this->order->expects($this->once())->method('addCommentToStatusHistory');
+ $this->order->expects($this->once())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame(200, $this->responseCode);
+ $this->assertSame('Order placed on hold', $this->responseData['message']);
+ }
+
+ public static function unsuccessfulStatusProvider(): array
+ {
+ return [['failed'], ['pending'], ['cancelled'], ['success']];
+ }
+
+ #[DataProvider('unsuccessfulStatusProvider')]
+ public function testNeverCancelsOrChangesOrderForUnsuccessfulPayment(string $status): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted(['status' => 'successful']));
+ $this->givenVerification(['status' => $status]);
+ $this->order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT);
+
+ $this->order->expects($this->never())->method('setState');
+ $this->order->expects($this->never())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame(200, $this->responseCode);
+ }
+
+ public static function finalStateProvider(): array
+ {
+ return [[Order::STATE_PROCESSING], [Order::STATE_COMPLETE], [Order::STATE_CANCELED], [Order::STATE_HOLDED], [Order::STATE_CLOSED]];
+ }
+
+ #[DataProvider('finalStateProvider')]
+ public function testLeavesOrdersOutsidePendingStatesUntouched(string $state): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted());
+ $this->givenVerification();
+ $this->order->method('getState')->willReturn($state);
+
+ $this->order->expects($this->never())->method('save');
+
+ $this->controller->execute();
+ $this->assertSame(200, $this->responseCode);
+ $this->assertSame('Order Already Processed', $this->responseData['message']);
+ }
+
+ public function testAcknowledgesTransactionsNotCreatedByThisStore(): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted());
+ $this->givenVerification(['tx_ref' => 'WOO_123_abc']);
+ $this->orderFactory->expects($this->never())->method('create');
+
+ $this->controller->execute();
+ $this->assertSame(200, $this->responseCode);
+ $this->assertSame('Transaction not handled by this store.', $this->responseData['message']);
+ }
+
+ public function testReturnsServerErrorWithoutLeakingDetailsWhenOrderMissing(): void
+ {
+ $this->givenRequest(self::SECRET, $this->chargeCompleted());
+ $this->givenVerification();
+ $missing = $this->createMock(Order::class);
+ $missing->method('loadByIncrementId')->willReturnSelf();
+ $missing->method('getId')->willReturn(null);
+ $orderFactory = $this->createMock(OrderFactory::class);
+ $orderFactory->method('create')->willReturn($missing);
+
+ $jsonFactory = $this->createMock(JsonFactory::class);
+ $jsonFactory->method('create')->willReturn($this->result);
+ $controller = new Webhook($this->createMock(LoggerInterface::class), $this->payment, $jsonFactory, $this->request, $orderFactory, $this->signozLogger);
+ $this->signozLogger->expects($this->once())->method('trackError')->with('webhook.exception');
+
+ $controller->execute();
+ $this->assertSame(500, $this->responseCode);
+ $this->assertSame('Webhook processing failed', $this->responseData['message']);
+ }
+
+ public function testCsrfValidationIsSkippedBecauseSignatureIsVerified(): void
+ {
+ $request = $this->createMock(RequestInterface::class);
+
+ $this->assertNull($this->controller->createCsrfValidationException($request));
+ $this->assertTrue($this->controller->validateForCsrf($request));
+ }
+
+ public static function amountsProvider(): array
+ {
+ return [
+ 'equal' => [100.00, 100.00, true],
+ 'within epsilon' => [100.00, 100.005, true],
+ 'outside epsilon' => [100.00, 100.02, false],
+ 'numeric strings' => ['100.00', '100', true],
+ ];
+ }
+
+ #[DataProvider('amountsProvider')]
+ public function testAmountsEqual(float|string $a, float|string $b, bool $expected): void
+ {
+ $this->assertSame($expected, $this->controller->amounts_equal($a, $b));
+ }
+}
diff --git a/Test/Unit/Controller/Redirect/IndexTest.php b/Test/Unit/Controller/Redirect/IndexTest.php
new file mode 100644
index 0000000..b40dcd1
--- /dev/null
+++ b/Test/Unit/Controller/Redirect/IndexTest.php
@@ -0,0 +1,64 @@
+payment = $this->createMock(Payment::class);
+ $this->messageManager = $this->createMock(ManagerInterface::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->redirect = $this->createMock(Redirect::class);
+ $this->redirect->method('setUrl')->willReturnSelf();
+ $this->redirect->method('setPath')->willReturnSelf();
+ $redirectFactory = $this->createMock(RedirectFactory::class);
+ $redirectFactory->method('create')->willReturn($this->redirect);
+
+ $this->controller = new Index($redirectFactory, $this->payment, $this->messageManager, $this->logger);
+ }
+
+ public function testRedirectsToFlutterwaveCheckout(): void
+ {
+ $this->payment->method('startTransaction')->willReturn('https://checkout.flutterwave.com/pay/x');
+ $this->redirect->expects($this->once())->method('setUrl')->with('https://checkout.flutterwave.com/pay/x');
+ $this->redirect->expects($this->never())->method('setPath');
+ $this->messageManager->expects($this->never())->method('addErrorMessage');
+
+ $this->assertSame($this->redirect, $this->controller->execute());
+ }
+
+ public function testReturnsToCartWhenNoLinkIsReturned(): void
+ {
+ $this->payment->method('startTransaction')->willReturn(false);
+ $this->redirect->expects($this->once())->method('setPath')->with('checkout/cart');
+ $this->messageManager->expects($this->once())->method('addErrorMessage');
+
+ $this->assertSame($this->redirect, $this->controller->execute());
+ }
+
+ public function testReturnsToCartAndLogsWhenStartingTransactionFails(): void
+ {
+ $this->payment->method('startTransaction')->willThrowException(new \RuntimeException('boom'));
+ $this->logger->expects($this->once())->method('debug')->with($this->stringContains('boom'));
+ $this->redirect->expects($this->once())->method('setPath')->with('checkout/cart');
+
+ $this->assertSame($this->redirect, $this->controller->execute());
+ }
+}
diff --git a/Test/Unit/Gateway/Command/InitializeCommandTest.php b/Test/Unit/Gateway/Command/InitializeCommandTest.php
new file mode 100644
index 0000000..ff10de5
--- /dev/null
+++ b/Test/Unit/Gateway/Command/InitializeCommandTest.php
@@ -0,0 +1,54 @@
+createMock(Order::class);
+ $order->method('getIncrementId')->willReturn('000000055');
+ $order->method('getGrandTotal')->willReturn(100.0);
+ $order->method('getOrderCurrencyCode')->willReturn('NGN');
+ $order->method('getStoreId')->willReturn(1);
+ $order->method('getCustomerEmail')->willReturn('buyer@example.com');
+ $order->method('getCustomerName')->willReturn('Ada Buyer');
+
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getOrder')->willReturn($order);
+ $paymentDO = $this->createMock(PaymentDataObjectInterface::class);
+ $paymentDO->method('getPayment')->willReturn($payment);
+
+ $client = $this->createMock(Client::class);
+ $client->expects($this->once())->method('createPaymentLink')->with([
+ 'tx_ref' => '000000055',
+ 'amount' => 100.0,
+ 'currency' => 'NGN',
+ 'redirect_url' => '',
+ 'customer' => ['email' => 'buyer@example.com', 'name' => 'Ada Buyer'],
+ ])->willReturn(['link' => 'https://checkout.flutterwave.com/x']);
+
+ $session = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->addMethods(['setFlutterwavePaymentUrl'])
+ ->getMock();
+ $session->expects($this->once())->method('setFlutterwavePaymentUrl')->with('https://checkout.flutterwave.com/x');
+
+ $stateObject = new DataObject();
+ (new InitializeCommand($client, $session))->execute(['payment' => $paymentDO, 'stateObject' => $stateObject]);
+
+ $this->assertSame(Order::STATE_PENDING_PAYMENT, $stateObject->getState());
+ $this->assertSame(Order::STATE_PENDING_PAYMENT, $stateObject->getStatus());
+ $this->assertFalse($stateObject->getIsNotified());
+ }
+}
diff --git a/Test/Unit/Gateway/Command/VerifyCommandTest.php b/Test/Unit/Gateway/Command/VerifyCommandTest.php
new file mode 100644
index 0000000..581f407
--- /dev/null
+++ b/Test/Unit/Gateway/Command/VerifyCommandTest.php
@@ -0,0 +1,85 @@
+client = $this->createMock(Client::class);
+ $this->payment = $this->createMock(Payment::class);
+ $this->payment->method('setTransactionId')->willReturnSelf();
+ $this->payment->method('setIsTransactionClosed')->willReturnSelf();
+ $this->payment->method('setAdditionalInformation')->willReturnSelf();
+ $this->paymentDO = $this->createMock(PaymentDataObjectInterface::class);
+ $this->paymentDO->method('getPayment')->willReturn($this->payment);
+ }
+
+ private function execute(): void
+ {
+ (new VerifyCommand($this->client))->execute(['payment' => $this->paymentDO, 'transaction_id' => '999111']);
+ }
+
+ private function givenPaymentAction(string $action): void
+ {
+ $method = $this->createMock(MethodInterface::class);
+ $method->method('getConfigPaymentAction')->willReturn($action);
+ $this->payment->method('getMethodInstance')->willReturn($method);
+ }
+
+ public static function unsuccessfulResponseProvider(): array
+ {
+ return [
+ 'failed' => [['status' => 'failed']],
+ 'legacy success value' => [['status' => 'success']],
+ 'no response' => [null],
+ ];
+ }
+
+ #[DataProvider('unsuccessfulResponseProvider')]
+ public function testThrowsWhenTransactionIsNotSuccessful(?array $response): void
+ {
+ $this->client->method('verifyTransaction')->with('999111')->willReturn($response);
+ $this->payment->expects($this->never())->method('setTransactionId');
+
+ $this->expectExceptionMessage('Payment verification failed');
+ $this->execute();
+ }
+
+ public function testRegistersCaptureForAuthorizeCapture(): void
+ {
+ $this->client->method('verifyTransaction')->willReturn(['status' => 'successful', 'amount' => 100]);
+ $this->givenPaymentAction('authorize_capture');
+
+ $this->payment->expects($this->once())->method('setTransactionId')->with('999111');
+ $this->payment->expects($this->once())->method('registerCaptureNotification')->with(100);
+ $this->payment->expects($this->never())->method('authorize');
+
+ $this->execute();
+ }
+
+ public function testAuthorizesForOtherPaymentActions(): void
+ {
+ $this->client->method('verifyTransaction')->willReturn(['status' => 'successful', 'amount' => 100]);
+ $this->givenPaymentAction('authorize');
+
+ $this->payment->expects($this->once())->method('authorize')->with(true, 100);
+ $this->payment->expects($this->never())->method('registerCaptureNotification');
+
+ $this->execute();
+ }
+}
diff --git a/Test/Unit/Model/Api/ClientTest.php b/Test/Unit/Model/Api/ClientTest.php
new file mode 100644
index 0000000..5de828d
--- /dev/null
+++ b/Test/Unit/Model/Api/ClientTest.php
@@ -0,0 +1,70 @@
+ 100,
+ 'currency' => 'NGN',
+ 'order_id' => '000000055',
+ 'tx_ref' => 'MAG_000000055_ab1',
+ 'customer' => ['email' => 'buyer@example.com', 'name' => 'Ada Buyer'],
+ ];
+
+ protected function setUp(): void
+ {
+ $this->curl = $this->createMock(Curl::class);
+ $this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
+ $this->urlBuilder = $this->createMock(UrlInterface::class);
+ $this->client = new Client($this->curl, $this->scopeConfig, $this->urlBuilder);
+ }
+
+ public function testCreatePaymentLinkPostsPayloadWithRedirectUrl(): void
+ {
+ $this->scopeConfig->method('getValue')
+ ->with('payment/flutterwave/api_key', ScopeInterface::SCOPE_STORE)
+ ->willReturn('FLWSECK_TEST');
+ $this->urlBuilder->method('getUrl')->with('flutterwave/payment/callback')->willReturn('https://shop.test/callback');
+
+ $this->curl->expects($this->once())->method('setHeaders')->with([
+ 'Authorization' => 'Bearer FLWSECK_TEST',
+ 'Content-Type' => 'application/json',
+ ]);
+ $this->curl->expects($this->once())->method('post')->with(
+ 'https://api.flutterwave.com/v3/payments',
+ json_encode($this->payload + ['redirect_url' => 'https://shop.test/callback'])
+ );
+ $this->curl->method('getBody')->willReturn(json_encode(['status' => 'success', 'data' => ['link' => 'https://checkout.flutterwave.com/x']]));
+
+ $this->assertSame(['link' => 'https://checkout.flutterwave.com/x'], $this->client->createPaymentLink($this->payload));
+ }
+
+ public function testCreatePaymentLinkReturnsFalseOnError(): void
+ {
+ $this->curl->method('getBody')->willReturn(json_encode(['status' => 'error', 'message' => 'Invalid key']));
+
+ $this->assertFalse($this->client->createPaymentLink($this->payload));
+ }
+
+ public function testVerifyTransactionIsNotImplemented(): void
+ {
+ $this->curl->expects($this->never())->method('get');
+
+ $this->assertNull($this->client->verifyTransaction('999111'));
+ }
+}
diff --git a/Test/Unit/Model/Config/EnvironmentTest.php b/Test/Unit/Model/Config/EnvironmentTest.php
new file mode 100644
index 0000000..2d0fa34
--- /dev/null
+++ b/Test/Unit/Model/Config/EnvironmentTest.php
@@ -0,0 +1,18 @@
+toOptionArray();
+
+ $this->assertSame(['sandbox', 'production'], array_column($options, 'value'));
+ $this->assertSame(['Sandbox', 'Production'], array_map('strval', array_column($options, 'label')));
+ }
+}
diff --git a/Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php b/Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php
new file mode 100644
index 0000000..91b1c2f
--- /dev/null
+++ b/Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php
@@ -0,0 +1,153 @@
+scopeConfig = $this->createMock(ScopeConfigInterface::class);
+ $this->curl = $this->createMock(Curl::class);
+ $this->curl->method('post')->willReturnCallback(function (string $url, string $body): void {
+ $this->assertSame(self::ENDPOINT, $url);
+ $this->sent = json_decode($body, true);
+ });
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->signoz = new FlutterwaveSignozLogger($this->scopeConfig, $this->curl, $this->logger);
+ }
+
+ private function givenStatus(int $status): void
+ {
+ $this->curl->method('getStatus')->willReturn($status);
+ }
+
+ public function testTrackRequestSentPostsEvent(): void
+ {
+ $this->givenStatus(202);
+ $this->curl->expects($this->once())->method('setHeaders')->with([
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+ ]);
+ $this->logger->expects($this->never())->method('warning');
+
+ $this->signoz->trackRequestSent('webhook', ' MAG_1_x ', '/flutterwave/payment/webhook', ['status' => 'successful']);
+
+ $this->assertSame('request.sent', $this->sent['name']);
+ $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/', $this->sent['timestamp']);
+ $this->assertSame([
+ 'app_id' => 'internal',
+ 'environment' => 'sandbox',
+ 'api_version' => 'v3',
+ 'library' => 'Magento',
+ 'library_version' => '2.x',
+ 'method' => 'webhook',
+ 'path' => '/flutterwave/payment/webhook',
+ 'reference' => 'MAG_1_x',
+ 'context' => ['status' => 'successful'],
+ ], $this->sent['data']);
+ }
+
+ public function testTrackTransactionPostsEvent(): void
+ {
+ $this->givenStatus(200);
+
+ $this->signoz->trackTransaction('MAG_1_x', 'NGN', 100.5, 'callback', 1.5, ['order_id' => '1']);
+
+ $this->assertSame('app.transaction', $this->sent['name']);
+ $this->assertSame('NGN', $this->sent['data']['currency']);
+ $this->assertEquals(100.5, $this->sent['data']['amount']);
+ $this->assertEquals(1.5, $this->sent['data']['fee']);
+ $this->assertSame('callback', $this->sent['data']['method']);
+ $this->assertSame(['order_id' => '1'], $this->sent['data']['context']);
+ }
+
+ public function testTrackErrorIncludesOptionalFieldsAndTruncates(): void
+ {
+ $this->givenStatus(200);
+
+ $this->signoz->trackError('webhook.exception', str_repeat('m', 5000), 'bad ref/../x', str_repeat('s', 20000), ['a' => 1]);
+
+ $data = $this->sent['data'];
+ $this->assertSame('app.error', $this->sent['name']);
+ $this->assertSame('webhook.exception', $data['error_code']);
+ $this->assertSame(4096, strlen($data['error_message']));
+ $this->assertSame(16384, strlen($data['error_stacktrace']));
+ $this->assertSame('bad-ref-x', $data['reference']);
+ }
+
+ public function testTrackErrorOmitsEmptyOptionalFields(): void
+ {
+ $this->givenStatus(200);
+
+ $this->signoz->trackError('callback.invalid', 'short', '', '');
+
+ $this->assertSame('short', $this->sent['data']['error_message']);
+ $this->assertArrayNotHasKey('error_stacktrace', $this->sent['data']);
+ $this->assertArrayNotHasKey('reference', $this->sent['data']);
+ }
+
+ public function testReferenceWithOnlyInvalidCharactersIsReplaced(): void
+ {
+ $this->givenStatus(200);
+
+ $this->signoz->trackRequestSent('callback', '', '/x');
+
+ $this->assertSame('unknown', $this->sent['data']['reference']);
+ }
+
+ public static function environmentProvider(): array
+ {
+ return [
+ ['live', 'production'],
+ ['production', 'production'],
+ ['1', 'production'],
+ ['sandbox', 'sandbox'],
+ [null, 'sandbox'],
+ ];
+ }
+
+ #[DataProvider('environmentProvider')]
+ public function testEnvironmentMapping(?string $configured, string $expected): void
+ {
+ $this->givenStatus(200);
+ $this->scopeConfig->method('getValue')->with('payment/flutterwave/environment')->willReturn($configured);
+
+ $this->signoz->trackRequestSent('webhook', 'r', '/x');
+
+ $this->assertSame($expected, $this->sent['data']['environment']);
+ }
+
+ public function testLogsWarningOnNonSuccessStatus(): void
+ {
+ $this->givenStatus(500);
+ $this->logger->expects($this->once())->method('warning')->with('Flutterwave SigNoz event failed with HTTP status: 500');
+
+ $this->signoz->trackRequestSent('webhook', 'r', '/x');
+ }
+
+ public function testNeverThrowsWhenTransportFails(): void
+ {
+ $curl = $this->createMock(Curl::class);
+ $curl->method('post')->willThrowException(new \RuntimeException('connection refused'));
+ $this->logger->expects($this->once())->method('warning')->with('Flutterwave SigNoz logger failed: connection refused');
+
+ (new FlutterwaveSignozLogger($this->scopeConfig, $curl, $this->logger))->trackRequestSent('webhook', 'r', '/x');
+ }
+}
diff --git a/Test/Unit/Model/Payment/FlutterwaveAdapterTest.php b/Test/Unit/Model/Payment/FlutterwaveAdapterTest.php
new file mode 100644
index 0000000..9c6f9a4
--- /dev/null
+++ b/Test/Unit/Model/Payment/FlutterwaveAdapterTest.php
@@ -0,0 +1,129 @@
+ 'Flutterwave', 'api_key' => 'FLWSECK_TEST'];
+
+ protected function setUp(): void
+ {
+ $this->client = $this->createMock(Client::class);
+ $this->session = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->addMethods(['setFlutterwavePaymentUrl'])
+ ->getMock();
+ $this->urlBuilder = $this->createMock(UrlInterface::class);
+ $this->urlBuilder->method('getUrl')->with('flutterwave/payment/callback', ['_secure' => true])->willReturn('https://shop.test/callback');
+ $this->signoz = $this->createMock(FlutterwaveSignozLogger::class);
+ }
+
+ private function createAdapter(?FlutterwaveSignozLogger $signoz): FlutterwaveAdapter
+ {
+ $handler = $this->createMock(ValueHandlerInterface::class);
+ $handler->method('handle')->willReturnCallback(fn (array $subject) => $this->config[$subject['field']] ?? null);
+ $pool = $this->createMock(ValueHandlerPoolInterface::class);
+ $pool->method('get')->willReturn($handler);
+
+ return new FlutterwaveAdapter(
+ $this->createMock(ManagerInterface::class),
+ $pool,
+ $this->createMock(PaymentDataObjectFactory::class),
+ FlutterwaveAdapter::CODE,
+ FormBlock::class,
+ InfoBlock::class,
+ $this->client,
+ $this->session,
+ $this->urlBuilder,
+ null,
+ null,
+ null,
+ $this->createMock(LoggerInterface::class),
+ $signoz
+ );
+ }
+
+ private function givenOrderOn(FlutterwaveAdapter $adapter): void
+ {
+ $order = $this->createMock(Order::class);
+ $order->method('getIncrementId')->willReturn('000000055');
+ $order->method('getGrandTotal')->willReturn(100.0);
+ $order->method('getOrderCurrencyCode')->willReturn('NGN');
+ $order->method('getCustomerEmail')->willReturn('buyer@example.com');
+ $order->method('getCustomerName')->willReturn('Ada Buyer');
+ $payment = $this->createMock(Payment::class);
+ $payment->method('getOrder')->willReturn($order);
+ $adapter->setInfoInstance($payment);
+ }
+
+ public function testInitializeCreatesLinkAndSetsPendingPayment(): void
+ {
+ $adapter = $this->createAdapter($this->signoz);
+ $this->givenOrderOn($adapter);
+
+ $this->signoz->expects($this->once())->method('trackRequestSent')->with(
+ 'payment_link',
+ $this->matchesRegularExpression('/^MAG_000000055_ab[0-9a-f]+$/'),
+ '/v3/payments',
+ ['order_id' => '000000055', 'amount' => 100.0, 'currency' => 'NGN']
+ );
+ $this->client->expects($this->once())->method('createPaymentLink')->with($this->callback(function (array $payload): bool {
+ $this->assertMatchesRegularExpression('/^MAG_000000055_ab[0-9a-f]+$/', $payload['tx_ref']);
+ $this->assertSame('https://shop.test/callback', $payload['redirect_url']);
+ $this->assertSame(['email' => 'buyer@example.com', 'name' => 'Ada Buyer'], $payload['customer']);
+ $this->assertSame(['title' => 'Flutterwave', 'logo' => ''], $payload['customizations']);
+ return true;
+ }))->willReturn(['link' => 'https://checkout.flutterwave.com/x']);
+ $this->session->expects($this->once())->method('setFlutterwavePaymentUrl')->with('https://checkout.flutterwave.com/x');
+
+ $state = new DataObject();
+ $this->assertSame($adapter, $adapter->initialize('authorize', $state));
+ $this->assertSame(Order::STATE_PENDING_PAYMENT, $state->getState());
+ $this->assertSame('pending_payment', $state->getStatus());
+ $this->assertFalse($state->getIsNotified());
+ }
+
+ public function testInitializeWorksWithoutSignozLoggerAndFailsWithoutLink(): void
+ {
+ $adapter = $this->createAdapter(null);
+ $this->givenOrderOn($adapter);
+ $this->client->method('createPaymentLink')->willReturn(false);
+ $this->session->expects($this->never())->method('setFlutterwavePaymentUrl');
+
+ $this->expectException(LocalizedException::class);
+ $this->expectExceptionMessage('Could not generate Flutterwave payment link');
+ $adapter->initialize('authorize', new DataObject());
+ }
+
+ public function testIsAvailableRequiresApiKey(): void
+ {
+ $this->assertTrue($this->createAdapter(null)->isAvailable());
+
+ $this->config['api_key'] = '';
+ $this->assertFalse($this->createAdapter(null)->isAvailable());
+ }
+}
diff --git a/Test/Unit/Model/PaymentTest.php b/Test/Unit/Model/PaymentTest.php
new file mode 100644
index 0000000..3c6e4ed
--- /dev/null
+++ b/Test/Unit/Model/PaymentTest.php
@@ -0,0 +1,169 @@
+checkoutSession = $this->getMockBuilder(Session::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getLastRealOrder'])
+ ->getMock();
+ $this->curl = $this->createMock(Curl::class);
+ $this->urlBuilder = $this->createMock(UrlInterface::class);
+ $this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
+ $this->encryptor = $this->createMock(EncryptorInterface::class);
+
+ $this->payment = new Payment(
+ $this->checkoutSession,
+ $this->createMock(Order::class),
+ $this->curl,
+ $this->urlBuilder,
+ $this->scopeConfig,
+ $this->encryptor
+ );
+ }
+
+ public function testGetSecretHashDecryptsConfiguredValue(): void
+ {
+ $this->scopeConfig->method('getValue')->with('payment/flutterwave/secret_hash')->willReturn('encrypted');
+ $this->encryptor->expects($this->once())->method('decrypt')->with('encrypted')->willReturn('my-hash');
+
+ $this->assertSame('my-hash', $this->payment->getSecretHash());
+ }
+
+ public function testGetSecretHashReturnsEmptyWhenNotConfigured(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn(null);
+ $this->encryptor->expects($this->never())->method('decrypt');
+
+ $this->assertSame('', $this->payment->getSecretHash());
+ }
+
+ #[DataProvider('txRefProvider')]
+ public function testGetOrderIncrementIdFromTxRef(string $txRef, ?string $expected): void
+ {
+ $this->assertSame($expected, $this->payment->getOrderIncrementIdFromTxRef($txRef));
+ }
+
+ public static function txRefProvider(): array
+ {
+ return [
+ 'valid' => ['MAG_000000055_old65f0a1', '000000055'],
+ 'wrong prefix' => ['WOO_000000055_old65f0a1', null],
+ 'non numeric id' => ['MAG_55a_old65f0a1', null],
+ 'missing suffix' => ['MAG_000000055', null],
+ 'extra segment' => ['MAG_000000055_x_y', null],
+ 'empty' => ['', null],
+ ];
+ }
+
+ public function testStartTransactionSendsV3PayloadAndReturnsLink(): void
+ {
+ $order = $this->createMock(Order::class);
+ $order->method('getGrandTotal')->willReturn(100.0);
+ $order->method('getOrderCurrencyCode')->willReturn('NGN');
+ $order->method('getIncrementId')->willReturn('000000055');
+ $order->method('getCustomerEmail')->willReturn('buyer@example.com');
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($order);
+ $this->scopeConfig->method('getValue')->with('payment/flutterwave/api_key')->willReturn('FLWSECK_TEST');
+ $this->urlBuilder->method('getUrl')->with('flutterwave/payment/callback')->willReturn('https://shop.test/flutterwave/payment/callback/');
+
+ $this->curl->expects($this->once())->method('setHeaders')->with([
+ 'Authorization' => 'Bearer FLWSECK_TEST',
+ 'Content-Type' => 'application/json',
+ ]);
+ $this->curl->expects($this->once())->method('post')->with(
+ 'https://api.flutterwave.com/v3/payments',
+ $this->callback(function (string $body): bool {
+ $data = json_decode($body, true);
+ $this->assertSame(100.0, (float) $data['amount']);
+ $this->assertSame('NGN', $data['currency']);
+ $this->assertMatchesRegularExpression('/^MAG_000000055_old[0-9a-f]+$/', $data['tx_ref']);
+ $this->assertSame('https://shop.test/flutterwave/payment/callback/?client_reference=000000055', $data['redirect_url']);
+ $this->assertSame(['email' => 'buyer@example.com'], $data['customer']);
+ $this->assertSame('000000055', $this->payment->getOrderIncrementIdFromTxRef($data['tx_ref']));
+ return true;
+ })
+ );
+ $this->curl->method('getBody')->willReturn(json_encode(['data' => ['link' => 'https://checkout.flutterwave.com/x']]));
+
+ $this->assertSame('https://checkout.flutterwave.com/x', $this->payment->startTransaction());
+ }
+
+ public function testStartTransactionReturnsFalseWithoutLink(): void
+ {
+ $this->checkoutSession->method('getLastRealOrder')->willReturn($this->createMock(Order::class));
+ $this->curl->method('getBody')->willReturn(json_encode(['status' => 'error']));
+
+ $this->assertFalse($this->payment->startTransaction());
+ }
+
+ public function testVerifyTransactionReturnsNormalisedData(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('FLWSECK_TEST');
+ $this->curl->expects($this->once())->method('get')->with('https://api.flutterwave.com/v3/transactions/999111/verify');
+ $this->curl->method('getBody')->willReturn(json_encode([
+ 'status' => 'success',
+ 'data' => [
+ 'id' => 999111,
+ 'tx_ref' => 'MAG_000000055_old1',
+ 'amount' => 100,
+ 'currency' => 'NGN',
+ 'status' => 'successful',
+ ],
+ ]));
+
+ $this->assertSame([
+ 'id' => '999111',
+ 'tx_ref' => 'MAG_000000055_old1',
+ 'amount' => 100,
+ 'currency' => 'NGN',
+ 'status' => 'successful',
+ ], $this->payment->verifyTransaction('999111'));
+ }
+
+ #[DataProvider('nonNumericIdProvider')]
+ public function testVerifyTransactionRejectsNonNumericIdWithoutCallingApi(string $id): void
+ {
+ $this->curl->expects($this->never())->method('get');
+
+ $this->assertSame([], $this->payment->verifyTransaction($id));
+ }
+
+ public static function nonNumericIdProvider(): array
+ {
+ return [
+ 'tx_ref' => ['MAG_000000055_old1'],
+ 'path traversal' => ['1/../../payments'],
+ 'empty' => [''],
+ ];
+ }
+
+ public function testVerifyTransactionReturnsEmptyForIncompleteResponse(): void
+ {
+ $this->curl->method('getBody')->willReturn(json_encode(['status' => 'error', 'data' => ['id' => 1]]));
+
+ $this->assertSame([], $this->payment->verifyTransaction('1'));
+ }
+}
diff --git a/Test/bootstrap.php b/Test/bootstrap.php
new file mode 100644
index 0000000..7ca19f5
--- /dev/null
+++ b/Test/bootstrap.php
@@ -0,0 +1,25 @@
+ [path-prefix]
+ * With a path prefix (e.g. "Controller/"), only files under that directory are counted.
+ */
+declare(strict_types=1);
+
+[$script, $cloverFile, $threshold, $prefix] = array_pad($argv, 4, '');
+
+if ($cloverFile === '' || !is_file($cloverFile) || !is_numeric($threshold)) {
+ fwrite(STDERR, "Usage: php {$script} [path-prefix]\n");
+ exit(2);
+}
+
+$root = rtrim(dirname(__DIR__), '/') . '/';
+$prefix = trim($prefix, '/');
+$covered = 0;
+$total = 0;
+$rows = [];
+
+foreach ((new SimpleXMLElement(file_get_contents($cloverFile)))->xpath('//file') as $file) {
+ $path = str_starts_with((string) $file['name'], $root) ? substr((string) $file['name'], strlen($root)) : (string) $file['name'];
+ if ($prefix !== '' && !str_starts_with($path, $prefix . '/')) {
+ continue;
+ }
+ $metrics = $file->metrics;
+ $statements = (int) $metrics['statements'];
+ $coveredStatements = (int) $metrics['coveredstatements'];
+ $total += $statements;
+ $covered += $coveredStatements;
+ if ($statements > 0) {
+ $rows[] = sprintf(' %6.2f%% %s', 100 * $coveredStatements / $statements, $path);
+ }
+}
+
+if ($total === 0) {
+ fwrite(STDERR, "No executable lines found" . ($prefix !== '' ? " under {$prefix}/" : '') . ".\n");
+ exit(1);
+}
+
+$percent = 100 * $covered / $total;
+echo implode("\n", $rows), "\n";
+printf("Line coverage%s: %.2f%% (%d/%d), minimum %.2f%%\n", $prefix !== '' ? " for {$prefix}" : '', $percent, $covered, $total, (float) $threshold);
+
+exit($percent + 1e-9 >= (float) $threshold ? 0 : 1);
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..aead92e
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,49 @@
+{
+ "name": "flutterwave/module-payment",
+ "description": "Flutterwave payment method for Magento 2",
+ "type": "magento2-module",
+ "license": "MIT",
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "magento/framework": "*",
+ "magento/module-backend": "*",
+ "magento/module-checkout": "*",
+ "magento/module-config": "*",
+ "magento/module-payment": "*",
+ "magento/module-quote": "*",
+ "magento/module-sales": "*",
+ "magento/module-store": "*",
+ "phpunit/phpunit": "^10.5"
+ },
+ "repositories": [
+ {
+ "type": "composer",
+ "url": "https://mirror.mage-os.org/",
+ "only": ["magento/*"]
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Flutterwave\\Payment\\": ""
+ },
+ "files": [
+ "registration.php"
+ ],
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "scripts": {
+ "test": "phpunit",
+ "test:coverage": "phpunit --coverage-clover build/coverage/clover.xml && php Test/coverage-check.php build/coverage/clover.xml 90"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": false,
+ "audit": {
+ "block-insecure": false
+ }
+ }
+}
diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml
index 360978c..8f81acc 100644
--- a/etc/adminhtml/system.xml
+++ b/etc/adminhtml/system.xml
@@ -23,6 +23,12 @@
+
+
+ Magento\Config\Model\Config\Backend\Encrypted
+ Must match the Secret Hash set on the Flutterwave dashboard webhook settings. Webhooks are rejected while this is empty.
+
+
Flutterwave\Payment\Model\Config\Source\Environment
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..dc41588
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,31 @@
+
+
+
+
+ Test/Unit/Block
+
+
+ Test/Unit/Controller
+
+
+ Test/Unit/Gateway
+
+
+ Test/Unit/Model
+
+
+
+
+ Block
+ Controller
+ Gateway
+ Model
+
+
+