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
5 changes: 5 additions & 0 deletions .github/workflows/release-zip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
96 changes: 96 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/vendor/
/composer.lock
/build/
/.phpunit.cache/
.DS_Store
26 changes: 15 additions & 11 deletions Controller/Debug/Check.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<h1>Flutterwave Payment Method Debug</h1>";
echo "<pre>";
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 "</pre>";
$output = "<h1>Flutterwave Payment Method Debug</h1>";
$output .= "<pre>";
$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 .= "</pre>";
} catch (\Exception $e) {
echo "<h1>Error</h1>";
echo "An error occurred: " . $e->getMessage();
$output = "<h1>Error</h1>";
$output .= "An error occurred: " . htmlspecialchars($e->getMessage(), ENT_QUOTES);
}

exit;
return $this->resultRawFactory->create()->setContents($output);
}
}
83 changes: 50 additions & 33 deletions Controller/Payment/Callback.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']
) {
Expand All @@ -103,39 +132,27 @@ 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)
->setStatus(Order::STATE_PROCESSING);
$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');
}
Expand Down
Loading
Loading