From d39b626164c5515887cce9a48b3d87f447e465e9 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Mon, 28 Sep 2026 17:34:11 +0200 Subject: [PATCH 1/3] feat: supersede TransactionValidator with a settlement-gate design (Item A) Replaces the R2.1 TransactionValidator/SubunitConverter design (PR #76) with the reconciled version from the abandoned fix/r2.1-settlement-gate branch (2026-08-21, never opened as a PR, found by accident this session): a single settlementFailureReason() call owning status, currency, method, and amount checks, plus retry/customer-messaging policy (isPermanentForWebhook/isTerminalForCustomer/customerMessage). Adds a domain/mode check (test vs live) neither branch had, correctly placed after every amount-rejection path so it can't silently change the webhook's 200-vs-503 decision for a multi-axis failure - a bug an adversarial panel caught in an earlier placement. Adds a two-field overpayment check using data.requested_amount vs data.amount, verified against real Paystack fee data: local NGN cards are 1.5%+N100 capped at N2,000, international cards are 3.9%+N100 uncapped, so a hard bound on the paid amount alone would reject legitimate customer-bears-fee transactions. The requested_amount comparison keeps a tight, symmetric +/-1-subunit window (catching a wrong-exponent client bug) while the paid amount only has to cover what was requested, with no cap on the fee itself. Setup.php's existing fail-closed guard against a non-positive amount is preserved, now sourced from TransactionValidator::expectedSubunits() instead of the deleted SubunitConverter. Went through a 2-critic panel on the plan, a second targeted review of the implementation (caught an asymmetric tolerance window and a missing-domain-field policy contradiction), and a dedicated test pass that added three more mutation-verified edge cases. Co-Authored-By: Claude Sonnet 5 --- .../Payment/AbstractPaystackStandard.php | 18 +- Controller/Payment/Setup.php | 4 +- Gateway/PaystackApiClient.php | 3 +- Gateway/SubunitConverter.php | 45 - Gateway/Validator/TransactionValidator.php | 575 ++++++--- Test/Unit/Controller/Payment/CallbackTest.php | 4 +- Test/Unit/Controller/Payment/RecreateTest.php | 4 +- Test/Unit/Controller/Payment/SetupTest.php | 83 +- Test/Unit/Controller/Payment/WebhookTest.php | 4 +- Test/Unit/Gateway/SubunitConverterTest.php | 60 - .../Validator/TransactionValidatorTest.php | 1149 ++++++++++++++--- 11 files changed, 1459 insertions(+), 490 deletions(-) delete mode 100644 Gateway/SubunitConverter.php delete mode 100644 Test/Unit/Gateway/SubunitConverterTest.php diff --git a/Controller/Payment/AbstractPaystackStandard.php b/Controller/Payment/AbstractPaystackStandard.php index 43a267f..63bb89b 100644 --- a/Controller/Payment/AbstractPaystackStandard.php +++ b/Controller/Payment/AbstractPaystackStandard.php @@ -82,6 +82,12 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac */ protected $request; + /** + * + * @var \Pstk\Paystack\Gateway\Validator\TransactionValidator + */ + protected $transactionValidator; + /** * Constructor * @@ -101,7 +107,8 @@ public function __construct( \Magento\Framework\Event\Manager $eventManager, \Magento\Framework\App\Request\Http $request, \Psr\Log\LoggerInterface $logger, - PaystackApiClient $paystackClient + PaystackApiClient $paystackClient, + ?\Pstk\Paystack\Gateway\Validator\TransactionValidator $transactionValidator = null ) { $this->resultPageFactory = $resultPageFactory; $this->orderRepository = $orderRepository; @@ -115,6 +122,15 @@ public function __construct( $this->request = $request; $this->logger = $logger; $this->paystackClient = $paystackClient; + // Defaulted, not because the settlement gate is optional — it is not, and + // a null here would silently disable it — but because this is a public + // abstract base in a Marketplace-distributed module. A required parameter + // added to it fatals any third-party subclass on upgrade. Magento's own + // convention for widening a released constructor applies: fall back to the + // ObjectManager so the guard is always present either way. + $this->transactionValidator = $transactionValidator + ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Pstk\Paystack\Gateway\Validator\TransactionValidator::class); parent::__construct($context); } diff --git a/Controller/Payment/Setup.php b/Controller/Payment/Setup.php index 1e678b5..65be86a 100644 --- a/Controller/Payment/Setup.php +++ b/Controller/Payment/Setup.php @@ -22,8 +22,6 @@ namespace Pstk\Paystack\Controller\Payment; -use Pstk\Paystack\Gateway\SubunitConverter; - class Setup extends AbstractPaystackStandard { /** @@ -67,7 +65,7 @@ protected function processAuthorization(\Magento\Sales\Model\Order $order) { // customer "pay" nothing with a success response and no trace. The caller // turns this into order history plus the failure page. $grandTotal = $order->getGrandTotal(); - $amount = is_numeric($grandTotal) ? SubunitConverter::toSubunit($grandTotal) : 0; + $amount = is_numeric($grandTotal) ? $this->transactionValidator->expectedSubunits($order) : 0; if ($amount <= 0) { throw new \Pstk\Paystack\Gateway\Exception\ApiException( 'Cannot start a Paystack transaction: the order has no valid grand total.' diff --git a/Gateway/PaystackApiClient.php b/Gateway/PaystackApiClient.php index 1f43d28..994cbfe 100644 --- a/Gateway/PaystackApiClient.php +++ b/Gateway/PaystackApiClient.php @@ -42,7 +42,8 @@ private function getSecretKey(): string * source of truth for `payment/pstk_paystack/test_mode` — `getSecretKey()` * calls this internally rather than reading the config a second time, so * the two can never independently drift (the same drift class - * `Gateway/SubunitConverter.php` exists to prevent). + * `Gateway/Validator/TransactionValidator::expectedSubunits()` exists to + * prevent). * * @return bool */ diff --git a/Gateway/SubunitConverter.php b/Gateway/SubunitConverter.php deleted file mode 100644 index 0cc1716..0000000 --- a/Gateway/SubunitConverter.php +++ /dev/null @@ -1,45 +0,0 @@ - you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see //www.gnu.org/licenses/>. - */ - -namespace Pstk\Paystack\Gateway; - -/** - * Static and untyped on purpose: `sales_order.grand_total` arrives from PDO as - * a numeric string. Any FUTURE caller file that declares `strict_types=1` - * would get a `TypeError` calling this with a numeric string like - * `'19.9900'`, and this module's money-path callers swallow `\Throwable` — - * turning that into a silent refusal of every paid order. Untyped and - * non-strict on purpose. Shared by Controller/Payment/Setup.php (send side) - * and Gateway/Validator/TransactionValidator.php (accept side) so the two can - * never drift apart independently. - * - * `* 100` is correct for every currency Paystack supports, including - * zero-decimal currencies like XOF/RWF — verified, do not add a - * per-currency multiplier map. - */ -class SubunitConverter -{ - public static function toSubunit($amount): int - { - return (int) round(((float) $amount) * 100); - } -} diff --git a/Gateway/Validator/TransactionValidator.php b/Gateway/Validator/TransactionValidator.php index 16613f0..dffc369 100644 --- a/Gateway/Validator/TransactionValidator.php +++ b/Gateway/Validator/TransactionValidator.php @@ -1,192 +1,457 @@ you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see //www.gnu.org/licenses/>. - */ - namespace Pstk\Paystack\Gateway\Validator; use Magento\Sales\Api\Data\OrderInterface; -use Pstk\Paystack\Gateway\SubunitConverter; +use Pstk\Paystack\Model\Payment\Paystack; +use Psr\Log\LoggerInterface; /** - * Checks a Paystack "verify transaction" response against the Magento order - * it claims to pay for, on four axes: status, currency, amount (bounded - * window), and live/test domain. - * - * What this validator does NOT establish — `validate() === []` means the - * response passed these four checks, nothing more: - * - reference↔order binding: no binding between a Paystack reference and a - * Magento order exists anywhere in this module today; a caller that - * advances an order on `validate() === []` alone would allow one charge - * to settle two orders. That binding is a separate concern owned - * elsewhere, not here. - * - order state: a `canceled`/`closed` order passes all four axes just the - * same as a fresh one — state is a separate concern owned elsewhere. - * - replay/idempotency: a known-successful reference re-validates - * indefinitely — nothing here tracks "already consumed." - * Callers must not treat an empty array as "safe to advance this order" on - * its own; it is one input among several owned by other classes. - * - * The `message` strings in this class are log/order-history-facing only and - * must never be reflected back to an anonymous caller — there is a known - * reflection leak elsewhere in this codebase via - * `PaymentManagement::verifyPayment()`'s `\Throwable` catch; do not repeat - * that pattern with these messages. + * Confirms a Paystack verify response actually settles a given order before any + * caller advances it: same currency, `paid >= expected` subunits, `success` status, + * placed with the Paystack payment method, and live/test domain matching the + * store's configured mode. * - * Why not `Magento\Payment\Gateway\Validator\ValidatorInterface` / - * `Result` / `ValidatorPool`: this module has no gateway-command DI wiring - * anywhere — no `ValidatorPool`/`CommandPool` in any `etc/**\/di.xml`, and - * `Model/Payment/Paystack.php` is a hand-rolled `MethodInterface` - * implementation (`extends DataObject implements MethodInterface`), not - * `AbstractMethod`- or `Adapter`-based. Adopting Magento's validator - * interface here would fight the module's actual architecture for no - * consumer that asks for it. + * expectedSubunits() is the server-side owner of the amount formula + * Controller/Payment/Setup.php uses to initialize a transaction. The inline + * checkout path does not call this method: it computes the same ×100 rounding + * client-side from `quote.totals().grand_total`, so the two formulas must be + * kept in step by hand. This class does not prevent that divergence — the + * settlement gate below is what catches it, by rejecting a paid amount that + * doesn't match what this method says was expected. Zero-decimal currencies + * (e.g. XOF, RWF) inherit the pre-existing `x100` convention from + * initialization as-is, so the comparison here stays consistent with init by + * construction even though Paystack's own subunit convention for those + * currencies is not separately modeled. */ class TransactionValidator { - const STATUS_NOT_SUCCESS = 'status_not_success'; - const STATUS_AWAITING_CONFIRMATION = 'awaiting_confirmation'; - const CURRENCY_MISMATCH = 'currency_mismatch'; - const AMOUNT_MISMATCH = 'amount_mismatch'; - const AMOUNT_EXCESS = 'amount_excess'; - const ORDER_AMOUNT_INVALID = 'order_amount_invalid'; - const MODE_MISMATCH = 'mode_mismatch'; - - /** - * Tolerance above the expected subunit amount. Covers orders paid by the - * legacy `Math.ceil` build (pre-R1.2), which could send one subunit more - * than `SubunitConverter::toSubunit()` computes today for the same - * order — not unexplained slack, do not delete. Whoever registers the - * captured payment later must decide whether the verified `data.amount` - * (possibly one subunit over) or the order's own total is what gets - * recorded — this class does not decide that. - */ - const TOLERANCE_SUBUNITS = 1; - - /** - * @param object|null $verifyData Decoded `data` object from Paystack's - * verify-transaction response. Nullable and loosely typed on purpose: - * callers receive this from JSON decoding of a third-party response, - * so missing/malformed fields must fail closed, never throw. + /** + * REASON_MALFORMED means Paystack's own verify response could not be read + * (status/amount/currency missing or unusable) — money's fate is unknown, so + * it is never safe to retry. REASON_BAD_REFERENCE means the opposite: a + * reference the *client* sent us was unusable before any verify call was even + * made (e.g. PaymentManagement's `_-~-_` split guard) — nothing was charged + * through us, so it is safe to retry. Keeping them distinct lets + * isTerminalForCustomer() fail closed on the former and open on the latter. + */ + public const REASON_MALFORMED = 'malformed'; + public const REASON_BAD_REFERENCE = 'bad_reference'; + public const REASON_IN_FLIGHT = 'in_flight'; + public const REASON_NOT_SUCCESSFUL = 'not_successful'; + public const REASON_WRONG_METHOD = 'wrong_method'; + public const REASON_ZERO_TOTAL = 'zero_total'; + public const REASON_CURRENCY_MISMATCH = 'currency_mismatch'; + public const REASON_AMOUNT_MISMATCH = 'amount_mismatch'; + public const REASON_MODE_MISMATCH = 'mode_mismatch'; + + /** + * Tolerance above the *requested* subunit amount (see the two-field + * overpayment check in settlementFailureReason()). Covers orders paid by + * the legacy `Math.ceil` build (pre-R1.2), which could send one subunit + * more than today's rounding computes for the same order — not + * unexplained slack, do not delete. Previously (and incorrectly) applied + * to the *paid* amount instead of the *requested* amount; the two-field + * design corrects that. + */ + public const TOLERANCE_SUBUNITS = 1; + + /** + * The single list of "not yet settled" Paystack statuses — callers must + * read this rather than keep their own copy of the literal. + */ + public const STATUSES_IN_FLIGHT = ['pending', 'ongoing', 'queued']; + + /** + * Reasons safe to invite a retry for: nothing was charged through us, or + * Paystack's own record says the attempt was not charged. Everything else + * fails closed in isTerminalForCustomer() — money moved, or its fate is + * unknown, so re-enabling payment risks a double charge. + */ + private const RETRYABLE_FOR_CUSTOMER = [ + self::REASON_NOT_SUCCESSFUL, + self::REASON_BAD_REFERENCE, + ]; + + /** + * Reasons that describe what the money actually did — a retry of the exact + * same webhook event cannot change the answer, so these are the only ones + * safe to report permanent (200, no retry) to Paystack. Everything else, + * including any reason this list does not recognise (and, deliberately, + * REASON_MODE_MISMATCH and REASON_WRONG_METHOD — a misconfigured key pair + * or a payment-method mixup is not "the money definitively didn't + * settle"), is transient (503) in isPermanentForWebhook(): defaulting to + * transient is what keeps an unread response or a not-yet-hydrated + * payment relation from destroying a real payment's retry window. + */ + private const PERMANENT_FOR_WEBHOOK = [ + self::REASON_NOT_SUCCESSFUL, + self::REASON_AMOUNT_MISMATCH, + self::REASON_CURRENCY_MISMATCH, + self::REASON_ZERO_TOTAL, + ]; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * The subunit amount initialization sent Paystack for this order. + * + * A non-numeric grand total (import/headless/partial-migration order + * shapes) returns 0 rather than throwing, so it is caught by the + * existing REASON_ZERO_TOTAL check downstream instead of a TypeError. + * + * @param OrderInterface $order + * @return int + */ + public function expectedSubunits(OrderInterface $order): int + { + $total = $order->getGrandTotal(); + return is_numeric($total) ? (int) round(((float) $total) * 100) : 0; + } + + /** + * True when the order's payment method is Paystack — the single + * definition of "wrong method" used by settlementFailureReason()'s + * WRONG_METHOD guard. + * + * @param OrderInterface $order + * @return bool + */ + public function isPaystackOrder(OrderInterface $order): bool + { + $payment = $order->getPayment(); + return $payment !== null && $payment->getMethod() === Paystack::CODE; + } + + /** + * The integral subunit amount a verify response reports as paid, or null + * when the envelope is unreadable or the amount is not a whole number — + * the single parse settlementFailureReason() and isOverpayment() both + * build on. Note: 0 is a valid, readable amount and is returned as such; + * callers distinguishing "unreadable" from "zero paid" must check for + * `=== null`, not falsiness. + * + * @param object $verifyResponse Full envelope PaystackApiClient::verifyTransaction() returns + * @return int|null + */ + public function paidSubunits(object $verifyResponse): ?int + { + $data = $verifyResponse->data ?? null; + if (!is_object($data)) { + return null; + } + + $rawAmount = $data->amount ?? null; + if ($rawAmount === null || !$this->isIntegral($rawAmount)) { + return null; + } + + return (int) $rawAmount; + } + + /** + * The integral subunit amount a verify response reports as *requested* at + * initialize (`data.requested_amount`), or null when the envelope/field is + * unreadable, not a whole number, or absent on this channel. Same + * is-integral parse as paidSubunits(). Null is a legitimate, expected + * outcome here (not every channel populates this field) — callers must + * treat it as "fall back," not as a malformed response. + * + * @param object $verifyResponse Full envelope PaystackApiClient::verifyTransaction() returns + * @return int|null + */ + private function requestedSubunits(object $verifyResponse): ?int + { + $data = $verifyResponse->data ?? null; + if (!is_object($data)) { + return null; + } + + $rawAmount = $data->requested_amount ?? null; + if ($rawAmount === null || !$this->isIntegral($rawAmount)) { + return null; + } + + return (int) $rawAmount; + } + + /** + * @param object $verifyResponse Full envelope PaystackApiClient::verifyTransaction() returns * @param OrderInterface $order - * @param bool $testMode Whether the store is configured for Paystack test + * @param bool $testMode Whether the store is configured for Paystack test * mode. Must come from `PaystackApiClient::isTestMode()`, not from an - * independent config read — that closes the exact drift class this - * item's SubunitConverter change was designed to prevent, where two - * independently-maintained reads of the same config value can - * silently disagree. (Today `isTestMode()` and this validator are - * both unscoped/current-store; if a future caller resolves this - * against `$order->getStoreId()` while the client stays unscoped, a - * multi-website override makes the two disagree and real payments get - * held. Not fixed here.) - * @return array{code: string, message: string}[] Empty array means valid. - */ - public function validate(?object $verifyData, OrderInterface $order, bool $testMode): array + * independent config read — that would let the two silently disagree. + * @return string|null Null when settled for this order; otherwise the first failing REASON_* constant + */ + public function settlementFailureReason(object $verifyResponse, OrderInterface $order, bool $testMode): ?string { - $status = $verifyData->status ?? null; + $data = $verifyResponse->data ?? null; + if (!is_object($data)) { + return self::REASON_MALFORMED; + } + + $status = $data->status ?? null; + if (!is_string($status) || $status === '') { + return self::REASON_MALFORMED; + } + + $paid = $this->paidSubunits($verifyResponse); + if ($paid === null) { + return self::REASON_MALFORMED; + } + + $paidCurrency = $data->currency ?? null; + if (!is_string($paidCurrency) || $paidCurrency === '') { + return self::REASON_MALFORMED; + } + + if (in_array($status, self::STATUSES_IN_FLIGHT, true)) { + return self::REASON_IN_FLIGHT; + } if ($status !== 'success') { - // `pending`, `ongoing` and `queued` are in-flight, not failed: bank - // transfer and USSD sit there at verify time and settle minutes - // later via the webhook (same list as Controller/Payment/Callback.php). - // Collapsing this into STATUS_NOT_SUCCESS would regress that - // distinction for any caller that replaces its own inline check - // with this validator. - if (in_array($status, ['pending', 'ongoing', 'queued'], true)) { - return [ - ['code' => self::STATUS_AWAITING_CONFIRMATION, 'message' => 'Transaction is still being confirmed.'], - ]; - } + return self::REASON_NOT_SUCCESSFUL; + } + + $expected = $this->expectedSubunits($order); + $orderCurrency = $order->getOrderCurrencyCode(); - return [ - ['code' => self::STATUS_NOT_SUCCESS, 'message' => 'Transaction status is not success.'], - ]; + // All six values are settled from here on, so the context for every + // guard below (and the overpayment info-log) is built once. + $context = $this->allowListedContext($data, $order, $paid, $expected, $paidCurrency, $orderCurrency); + + if (!$this->isPaystackOrder($order)) { + $this->logSettlementProblem(self::REASON_WRONG_METHOD, $context); + return self::REASON_WRONG_METHOD; } - $errors = []; + if ($expected <= 0 || $paid <= 0) { + $this->logSettlementProblem(self::REASON_ZERO_TOTAL, $context); + return self::REASON_ZERO_TOTAL; + } - $rawCurrency = $verifyData->currency ?? ''; - $verifyCurrency = is_scalar($rawCurrency) ? strtoupper((string) $rawCurrency) : ''; - $orderCurrency = strtoupper((string) $order->getOrderCurrencyCode()); - if ($verifyCurrency === '' || $orderCurrency === '' || $verifyCurrency !== $orderCurrency) { - $errors[] = ['code' => self::CURRENCY_MISMATCH, 'message' => 'Transaction currency does not match order currency.']; + if (empty($orderCurrency) || strtoupper($paidCurrency) !== strtoupper($orderCurrency)) { + $this->logSettlementProblem(self::REASON_CURRENCY_MISMATCH, $context); + return self::REASON_CURRENCY_MISMATCH; } - $grandTotal = $order->getGrandTotal(); - $expected = is_numeric($grandTotal) ? SubunitConverter::toSubunit($grandTotal) : 0; - if ($expected <= 0) { - $errors[] = ['code' => self::ORDER_AMOUNT_INVALID, 'message' => 'Order amount is missing or invalid.']; + // Two-field overpayment check. `data.requested_amount` (the + // client-supplied figure at initialize) is checked against a tight + // window around `expected` — this catches a wrong-exponent client bug + // regardless of any fee added on top. `data.amount` (what was + // actually charged) only has to be at least what was requested — any + // excess is an uncapped, customer-borne fee, accepted and logged + // below. When `requested_amount` is absent/unreadable on this + // channel, fall back to the original, more permissive `paid >= + // expected` check rather than reject on a field this class cannot + // reliably read everywhere. + $requested = $this->requestedSubunits($verifyResponse); + if ($requested !== null) { + if ($requested < $expected - self::TOLERANCE_SUBUNITS || $requested > $expected + self::TOLERANCE_SUBUNITS) { + $this->logSettlementProblem(self::REASON_AMOUNT_MISMATCH, $context); + return self::REASON_AMOUNT_MISMATCH; + } + if ($paid < $requested) { + $this->logSettlementProblem(self::REASON_AMOUNT_MISMATCH, $context); + return self::REASON_AMOUNT_MISMATCH; + } } else { - $amount = $verifyData->amount ?? null; - if (!is_numeric($amount)) { - $errors[] = ['code' => self::AMOUNT_MISMATCH, 'message' => 'Transaction amount is missing or invalid.']; - } elseif ($amount < $expected) { - $errors[] = ['code' => self::AMOUNT_MISMATCH, 'message' => 'Transaction amount is less than the order total.']; - } elseif ($amount > $expected + self::TOLERANCE_SUBUNITS) { - $errors[] = ['code' => self::AMOUNT_EXCESS, 'message' => 'Transaction amount exceeds the order total.']; + if ($paid < $expected - self::TOLERANCE_SUBUNITS) { + $this->logSettlementProblem(self::REASON_AMOUNT_MISMATCH, $context); + return self::REASON_AMOUNT_MISMATCH; } } - /** - * This does NOT catch "live storefront left in test mode" — that - * scenario's `$testMode` is true by construction, since `test_mode=1` - * selects the test secret key that produced the test-domain response; - * the two are coupled at the source. What it actually catches is a - * swapped/misconfigured secret key pair (e.g. a test key configured - * where a live key belongs, or vice versa). - * - * A caller that needs to know whether real money moved must not infer - * it from which codes this method returns — call `chargeIsReal()` - * instead, which derives that fact directly from `$verifyData`. - */ - $domain = $verifyData->domain ?? null; - $expectedDomain = $testMode ? 'test' : 'live'; - if ($domain !== $expectedDomain) { - $errors[] = ['code' => self::MODE_MISMATCH, 'message' => 'Transaction domain does not match configured mode.']; - } - - return $errors; + // Placed after every amount rejection path (both the requested_amount + // branch and its fallback) and before the overpayment info-log — the + // only slot where no existing response's winning reason changes. A + // response failing on amount/currency/zero-total *and* mode must keep + // returning that reason (PERMANENT_FOR_WEBHOOK, 200), not + // REASON_MODE_MISMATCH (not permanent, 503 retries) — moving this + // check earlier would silently change webhook retry behavior for + // exactly that class of response. + // + // Read defensively: every other field in this method is `?? null` + + // type-checked before comparison, but `domain` only feeds this + // secondary "misconfigured key pair" sanity check, not the core + // settlement guarantee (unlike status/currency/paid amount above) — so + // an absent/renamed `domain` skips the mode check entirely rather than + // rejecting the whole payment, the same fail-open treatment + // requested_amount gets a few lines up. + $domain = $data->domain ?? null; + if (!is_string($domain) || $domain === '') { + $this->logger->warning('Paystack: mode check skipped, domain unreadable', $context); + } elseif ($domain !== ($testMode ? 'test' : 'live')) { + // Does NOT catch "live store left in test mode" — that scenario's + // $testMode is true by construction. Catches a swapped/ + // misconfigured secret-key pair. + $this->logSettlementProblem(self::REASON_MODE_MISMATCH, $context); + return self::REASON_MODE_MISMATCH; + } + + if ($this->isOverpayment($verifyResponse, $order)) { + if ($requested === null) { + // No requested_amount on this channel, so the tight + // wrong-exponent window a few lines up never ran — this + // overpayment has no backstop behind it, so it's more visible + // than the normal (requested_amount present) case below. + $this->logger->warning('Paystack: settlement overpayment', $context); + } else { + // Normal for Paystack's customer-bears-fee configuration, where + // data.amount = requested + fee. Info level keeps the signal usable. + $this->logger->info('Paystack: settlement overpayment', $context); + } + } + + return null; } /** - * Whether a Paystack verify response represents a real charge (money - * actually moved), independent of which — if any — codes `validate()` - * returns for the same `$verifyData`. + * True when the customer must NOT be invited to pay again for this reason. + * See RETRYABLE_FOR_CUSTOMER above for which reasons return false and why. * - * Derivation: currency/amount/domain are only checked by `validate()` - * when `$verifyData->status === 'success'`. A 'success' status on - * Paystack's *test* domain is a sandbox charge (no real money moves); - * 'success' on the *live* domain is a real charge. So "real money moved" - * is fully determined by `status === 'success' && domain === 'live'`. + * @param string $reason + * @return bool + */ + public function isTerminalForCustomer(string $reason): bool + { + return !in_array($reason, self::RETRYABLE_FOR_CUSTOMER, true); + } + + /** + * True when this reason is safe to report permanent (200, no retry) to + * Paystack. See PERMANENT_FOR_WEBHOOK above for which reasons return true + * and why. * - * This exists specifically so a future caller (e.g. R2.6, deciding - * whether to suppress a "retry" prompt) doesn't have to infer "did money - * move" from which `validate()` codes are present — `MODE_MISMATCH` alone - * does not tell you that, but this method does. + * @param string $reason + * @return bool + */ + public function isPermanentForWebhook(string $reason): bool + { + return in_array($reason, self::PERMANENT_FOR_WEBHOOK, true); + } + + /** + * Owns the customer-facing rejection copy for the REASON_* classifications + * reached through a verify response — or any caller-defined reason, since + * PaymentManagement also passes ad hoc strings ('error', 'quote_mismatch') + * for outcomes the verify response never even reached. Callback.php, + * Setup.php and Recreate.php each carry their own copy for non-verify + * outcomes that have no REASON_* here. + * + * @param string $reason + * @return string + */ + public function customerMessage(string $reason): string + { + switch ($reason) { + case self::REASON_NOT_SUCCESSFUL: + return "Your payment was not completed. Please try again."; + case self::REASON_BAD_REFERENCE: + return "Payment could not be verified. Please try again."; + case self::REASON_IN_FLIGHT: + return "Your payment is still being confirmed. Please do not pay " + . "again — we will email you once it is confirmed."; + default: + // Any reason not covered above, including one this class does + // not yet know about: money moved, or its fate is unknown, so + // the customer must not be told to just try again. Fails + // closed on wording exactly as isTerminalForCustomer() fails + // closed on terminality. + return "We could not confirm your payment. Please do not pay " + . "again — contact support with your order number."; + } + } + + /** + * Whether a verify response's paid amount exceeds this order's expected + * subunits — compared against `requested_amount` when readable (for + * consistency with the two-field check in settlementFailureReason()), or + * `expectedSubunits()` when it is not. Meaningful once + * settlementFailureReason() has already returned null for the same + * envelope/order pair; called against an unsettled pair it simply answers + * "no" rather than throwing, since it is a read, not a gate. * - * @param object|null $verifyData Same decoded `data` object passed to - * `validate()`. + * @param object $verifyResponse Full envelope PaystackApiClient::verifyTransaction() returns + * @param OrderInterface $order + * @return bool + */ + public function isOverpayment(object $verifyResponse, OrderInterface $order): bool + { + $paid = $this->paidSubunits($verifyResponse); + if ($paid === null) { + return false; + } + + $requested = $this->requestedSubunits($verifyResponse); + $baseline = $requested ?? $this->expectedSubunits($order); + + return $paid > $baseline; + } + + /** + * @param string|int|float $value * @return bool */ - public static function chargeIsReal(?object $verifyData): bool + private function isIntegral($value): bool + { + if (is_int($value)) { + return true; + } + if (is_float($value)) { + return $value === floor($value); + } + if (is_string($value) && is_numeric($value)) { + return (float) $value === floor((float) $value); + } + return false; + } + + /** + * @param string $reason + * @param array $context Allow-listed context from allowListedContext() + * @return void + */ + private function logSettlementProblem(string $reason, array $context): void { - return ($verifyData->status ?? null) === 'success' && ($verifyData->domain ?? null) === 'live'; + $this->logger->warning('Paystack: settlement check failed', $context + ['reason' => $reason]); + } + + /** + * Allow-listed log context only — never the raw response object, which carries + * card BIN/last4, customer email/phone, IP. + * + * @param object $data + * @param OrderInterface $order + * @param int $paid + * @param int $expected + * @param string $paidCurrency + * @param string|null $orderCurrency + * @return array + */ + private function allowListedContext( + object $data, + OrderInterface $order, + int $paid, + int $expected, + string $paidCurrency, + ?string $orderCurrency + ): array { + return [ + 'reference' => is_scalar($data->reference ?? null) + ? substr((string) ($data->reference ?? ''), 0, 100) + : null, + 'order_increment_id' => $order->getIncrementId(), + 'expected_amount' => $expected, + 'paid_amount' => $paid, + 'expected_currency' => $orderCurrency, + 'paid_currency' => $paidCurrency, + ]; } } diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php index 0b04515..3fff7b7 100644 --- a/Test/Unit/Controller/Payment/CallbackTest.php +++ b/Test/Unit/Controller/Payment/CallbackTest.php @@ -7,6 +7,7 @@ use Pstk\Paystack\Controller\Payment\Callback; use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Exception\ApiException; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Pstk\Paystack\Model\Ui\ConfigProvider; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; @@ -89,7 +90,8 @@ private function createController(): Callback $this->eventManager, $this->request, $this->logger, - $this->paystackClient + $this->paystackClient, + new TransactionValidator($this->createMock(LoggerInterface::class)) ); } diff --git a/Test/Unit/Controller/Payment/RecreateTest.php b/Test/Unit/Controller/Payment/RecreateTest.php index 7f06c09..ef108f4 100644 --- a/Test/Unit/Controller/Payment/RecreateTest.php +++ b/Test/Unit/Controller/Payment/RecreateTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\MockObject\MockObject; use Pstk\Paystack\Controller\Payment\Recreate; use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Pstk\Paystack\Model\Ui\ConfigProvider; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; @@ -66,7 +67,8 @@ private function createController(): Recreate $this->createMock(EventManager::class), $request, $this->createMock(LoggerInterface::class), - $this->createMock(PaystackApiClient::class) + $this->createMock(PaystackApiClient::class), + new TransactionValidator($this->createMock(LoggerInterface::class)) ); } diff --git a/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php index 05a8a5e..63a1d3d 100644 --- a/Test/Unit/Controller/Payment/SetupTest.php +++ b/Test/Unit/Controller/Payment/SetupTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\MockObject\MockObject; use Pstk\Paystack\Controller\Payment\Setup; use Pstk\Paystack\Gateway\PaystackApiClient; -use Pstk\Paystack\Gateway\SubunitConverter; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Pstk\Paystack\Gateway\Exception\ApiException; use Pstk\Paystack\Model\Payment\Paystack; use Pstk\Paystack\Model\Ui\ConfigProvider; @@ -55,6 +55,9 @@ class SetupTest extends TestCase /** @var MockObject|Redirect */ private $redirect; + /** @var MockObject|TransactionValidator */ + private $transactionValidator; + private function createController(): Setup { $this->paystackClient = $this->createMock(PaystackApiClient::class); @@ -65,6 +68,11 @@ private function createController(): Setup $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); $this->messageManager = $this->createMock(MessageManager::class); + // Each test stubs expectedSubunits() with the value it expects — see + // the individual tests below. Not called at all when the missing- + // currency/grand-total guards short-circuit first. + $this->transactionValidator = $this->createMock(TransactionValidator::class); + $this->redirect = $this->createMock(Redirect::class); $this->redirect->method('setUrl')->willReturnSelf(); @@ -100,7 +108,8 @@ private function createController(): Setup $this->createMock(EventManager::class), $request, $this->createMock(LoggerInterface::class), - $this->paystackClient + $this->paystackClient, + $this->transactionValidator ); } @@ -138,6 +147,8 @@ public function testSuccessfulSetupRedirectsToPaystack(): void $store->method('getBaseUrl')->willReturn('https://example.com/'); $this->storeManager->method('getStore')->willReturn($store); + $this->transactionValidator->method('expectedSubunits')->willReturn(500000); + $txResponse = (object) [ 'data' => (object) [ 'authorization_url' => 'https://checkout.paystack.com/abc123', @@ -175,13 +186,13 @@ public function testFractionalTotalsAreSentAsIntegerSubunits(float $grandTotal, { $controller = $this->createController(); $order = $this->primeOrder($grandTotal, 'NGN'); + $this->transactionValidator->method('expectedSubunits')->willReturn($expectedSubunits); $this->paystackClient->expects($this->once()) ->method('initializeTransaction') - ->with($this->callback(function ($params) use ($expectedSubunits, $grandTotal) { + ->with($this->callback(function ($params) use ($expectedSubunits) { return $params['amount'] === $expectedSubunits - && is_int($params['amount']) - && $params['amount'] === SubunitConverter::toSubunit($grandTotal); + && is_int($params['amount']); })) ->willReturn((object) ['data' => (object) ['authorization_url' => 'https://checkout.paystack.com/abc123']]); @@ -265,13 +276,13 @@ public function testMissingGrandTotalIsRejectedBeforeCallingPaystack(): void /** * A grand total of exactly 0.00 is numeric (unlike null), so it takes the - * `is_numeric($grandTotal) ? SubunitConverter::toSubunit(...) : 0` branch - * of the guard rather than the "not numeric" else — pinning that a + * `is_numeric($grandTotal) ? $this->transactionValidator->expectedSubunits($order) : 0` + * branch of the guard rather than the "not numeric" else — pinning that a * numeric-but-zero total is rejected too, not just a missing/non-numeric * one. Note: from this test's vantage point the observable outcome * (never call Paystack, same "grand total" history message) is identical * to testMissingGrandTotalIsRejectedBeforeCallingPaystack; it does not by - * itself prove SubunitConverter was reached rather than short-circuited, + * itself prove expectedSubunits() was reached rather than short-circuited, * only that this distinct input value produces the same fail-closed * result. */ @@ -280,6 +291,7 @@ public function testZeroGrandTotalIsRejectedBeforeCallingPaystack(): void $controller = $this->createController(); $order = $this->primeOrder(0.00, 'NGN'); $order->method('getStatus')->willReturn('pending'); + $this->transactionValidator->method('expectedSubunits')->willReturn(0); $this->paystackClient->expects($this->never())->method('initializeTransaction'); @@ -300,7 +312,60 @@ public function testNegativeGrandTotalIsRejectedBeforeCallingPaystack(): void $controller = $this->createController(); $order = $this->primeOrder(-19.99, 'NGN'); $order->method('getStatus')->willReturn('pending'); + $this->transactionValidator->method('expectedSubunits')->willReturn(-1999); + + $this->paystackClient->expects($this->never())->method('initializeTransaction'); + + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('grand total')); + + $controller->execute(); + } + + /** + * A non-numeric STRING grand total (distinct from null/0.00/negative, + * all of which are themselves numeric): Setup.php carries its own + * `is_numeric($grandTotal)` guard ahead of calling + * `$this->transactionValidator->expectedSubunits($order)` — this pins + * that end-to-end through Setup.php, not just inside + * TransactionValidator's own unit tests, and confirms + * expectedSubunits() is never even called in this case (the ternary's + * "not numeric" branch uses the literal 0 directly, short-circuiting the + * validator call entirely). + */ + public function testNonNumericStringGrandTotalIsRejectedBeforeCallingPaystack(): void + { + $controller = $this->createController(); + + $lastOrder = $this->createMock(Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn($payment); + $order->method('getStatus')->willReturn('pending'); + $order->method('getCustomerFirstname')->willReturn('John'); + $order->method('getCustomerLastname')->willReturn('Doe'); + $order->method('getGrandTotal')->willReturn('not-a-number'); + $order->method('getCustomerEmail')->willReturn('john@example.com'); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $methodInstance = $this->createMock(MethodInterface::class); + $methodInstance->method('getCode')->willReturn(Paystack::CODE); + $this->paymentHelper->method('getMethodInstance')->willReturn($methodInstance); + + $store = $this->createMock(Store::class); + $store->method('getBaseUrl')->willReturn('https://example.com/'); + $this->storeManager->method('getStore')->willReturn($store); + + $this->transactionValidator->expects($this->never())->method('expectedSubunits'); $this->paystackClient->expects($this->never())->method('initializeTransaction'); $order->expects($this->once()) @@ -377,6 +442,8 @@ public function testApiExceptionSavesStatusHistory(): void $store->method('getBaseUrl')->willReturn('https://example.com/'); $this->storeManager->method('getStore')->willReturn($store); + $this->transactionValidator->method('expectedSubunits')->willReturn(10000); + $this->paystackClient->method('initializeTransaction') ->willThrowException(new ApiException('Invalid key')); diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php index 53821f6..45e66b9 100644 --- a/Test/Unit/Controller/Payment/WebhookTest.php +++ b/Test/Unit/Controller/Payment/WebhookTest.php @@ -7,6 +7,7 @@ use Pstk\Paystack\Controller\Payment\Webhook; use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Exception\ApiException; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Pstk\Paystack\Model\Ui\ConfigProvider; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; @@ -104,7 +105,8 @@ protected function setUp(): void $this->eventManager, $this->request, $this->logger, - $this->paystackClient + $this->paystackClient, + new TransactionValidator($this->createMock(LoggerInterface::class)) ); } diff --git a/Test/Unit/Gateway/SubunitConverterTest.php b/Test/Unit/Gateway/SubunitConverterTest.php deleted file mode 100644 index 2d8869f..0000000 --- a/Test/Unit/Gateway/SubunitConverterTest.php +++ /dev/null @@ -1,60 +0,0 @@ -assertSame(500000, SubunitConverter::toSubunit(5000.00)); - } - - /** - * @dataProvider inexactAmountProvider - */ - public function testFractionalAmountsRoundToNearestSubunit(float $amount, int $expected): void - { - $this->assertSame($expected, SubunitConverter::toSubunit($amount)); - } - - public static function inexactAmountProvider(): array - { - return [ - '19.99 -> 1999' => [19.99, 1999], - '8.21 -> 821' => [8.21, 821], - '1.10 -> 110' => [1.10, 110], - '0.29 -> 29' => [0.29, 29], - ]; - } - - public function testNumericStringDoesNotThrow(): void - { - $this->assertSame(1999, SubunitConverter::toSubunit('19.9900')); - } - - public function testZeroConvertsToZero(): void - { - $this->assertSame(0, SubunitConverter::toSubunit(0)); - } - - public function testNegativeAmountConvertsToNegativeSubunit(): void - { - $this->assertSame(-1999, SubunitConverter::toSubunit(-19.99)); - } - - /** - * Both current callers (Setup::processAuthorization and - * TransactionValidator::validate) guard with is_numeric() before calling - * this, so a non-numeric string is unreachable today. But this class's - * own docblock says it exists so the two callers can never independently - * drift — a future caller that drops the is_numeric guard must still get - * a fail-closed 0, not a silent cast surprise or a thrown TypeError. - */ - public function testNonNumericStringConvertsToZeroNotThrown(): void - { - $this->assertSame(0, SubunitConverter::toSubunit('abc')); - } -} diff --git a/Test/Unit/Gateway/Validator/TransactionValidatorTest.php b/Test/Unit/Gateway/Validator/TransactionValidatorTest.php index 47eefd6..7979e09 100644 --- a/Test/Unit/Gateway/Validator/TransactionValidatorTest.php +++ b/Test/Unit/Gateway/Validator/TransactionValidatorTest.php @@ -5,419 +5,1140 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; use Pstk\Paystack\Gateway\Validator\TransactionValidator; -use Magento\Sales\Api\Data\OrderInterface; +use Pstk\Paystack\Model\Payment\Paystack; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Psr\Log\LoggerInterface; class TransactionValidatorTest extends TestCase { + /** @var MockObject|LoggerInterface */ + private $logger; + /** @var TransactionValidator */ private $validator; protected function setUp(): void { - $this->validator = new TransactionValidator(); + $this->logger = $this->createMock(LoggerInterface::class); + $this->validator = new TransactionValidator($this->logger); } /** - * @param float|string|null $grandTotal - * @param string $currencyCode - * @return MockObject|OrderInterface + * @return MockObject|Order */ - private function makeOrder($grandTotal, $currencyCode) + private function makeOrder(float $grandTotal, ?string $currencyCode, string $method = Paystack::CODE) { - $order = $this->createMock(OrderInterface::class); + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn($method); + + $order = $this->createMock(Order::class); $order->method('getGrandTotal')->willReturn($grandTotal); $order->method('getOrderCurrencyCode')->willReturn($currencyCode); + $order->method('getPayment')->willReturn($payment); + $order->method('getIncrementId')->willReturn('000000001'); return $order; } - private function makeVerifyData(array $overrides = []) + private function verifyResponse(array $overrides = []): object { - $data = (object) array_merge([ + $data = array_merge([ 'status' => 'success', + 'amount' => 500000, 'currency' => 'NGN', - 'amount' => 10000, + 'reference' => 'PSK_abc123', 'domain' => 'test', ], $overrides); - return $data; + return (object) ['data' => (object) $data]; } - public function testAllValidReturnsEmptyArray(): void + public function testExactMatchPasses(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $result = $this->validator->validate($this->makeVerifyData(), $order, true); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000, 'currency' => 'NGN']); - $this->assertSame([], $result); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testDeclinedStatusShortCircuitsEvenWhenOthersWouldAlsoFail(): void + public function testShortByOneSubunitPassesWithinSymmetricToleranceWindow(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData([ - 'status' => 'failed', - 'currency' => 'USD', - 'amount' => 1, - 'domain' => 'live', - ]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499999]); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } - $this->assertCount(1, $result); - $this->assertSame(TransactionValidator::STATUS_NOT_SUCCESS, $result[0]['code']); + public function testShortByTwoSubunitsRejects(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499998]); + + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testCurrencyMismatchFailsIndependently(): void + /** + * Fallback branch (no requested_amount readable on this channel): the + * tight wrong-exponent window never ran, so this overpayment has no + * backstop behind it — logged at warning, not info. + */ + public function testOverpayByOneWithNoRequestedAmountPassesAndLogsWarning(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['currency' => 'USD']); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500001]); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertArrayNotHasKey('requested_amount', (array) $response->data); + $this->logger->expects($this->once())->method('warning'); + $this->logger->expects($this->never())->method('info'); - $this->assertSame([TransactionValidator::CURRENCY_MISMATCH], array_column($result, 'code')); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testAmountMismatchFailsIndependently(): void + public function testCurrencyMismatchRejects(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 1]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['currency' => 'USD']); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('warning'); - $this->assertSame([TransactionValidator::AMOUNT_MISMATCH], array_column($result, 'code')); + $this->assertSame( + TransactionValidator::REASON_CURRENCY_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testDomainMismatchFailsIndependently(): void + public function testCaseInsensitiveCurrencyPasses(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['domain' => 'live']); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['currency' => 'ngn']); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } - $result = $this->validator->validate($verifyData, $order, true); + public function testMissingDataRejectsAsMalformedWithoutLogging(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = (object) []; - $this->assertSame([TransactionValidator::MODE_MISMATCH], array_column($result, 'code')); + $this->logger->expects($this->never())->method($this->anything()); + + $this->assertSame( + TransactionValidator::REASON_MALFORMED, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testLiveDomainMatchesWhenTestModeIsFalse(): void + public function testMissingAmountRejectsAsMalformed(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['domain' => 'live']); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = (object) ['data' => (object) ['status' => 'success', 'currency' => 'NGN', 'domain' => 'test']]; - $result = $this->validator->validate($verifyData, $order, false); + $this->assertSame( + TransactionValidator::REASON_MALFORMED, + $this->validator->settlementFailureReason($response, $order, true) + ); + } - $this->assertSame([], $result); + public function testMissingCurrencyRejectsAsMalformed(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = (object) ['data' => (object) ['status' => 'success', 'amount' => 500000, 'domain' => 'test']]; + + $this->assertSame( + TransactionValidator::REASON_MALFORMED, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testEveryErrorHasCodeAndMessageKeys(): void + public function testMissingStatusRejectsAsMalformed(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData([ - 'currency' => 'USD', - 'amount' => 1, - 'domain' => 'live', - ]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = (object) ['data' => (object) ['amount' => 500000, 'currency' => 'NGN', 'domain' => 'test']]; - $result = $this->validator->validate($verifyData, $order, true); + $this->assertSame( + TransactionValidator::REASON_MALFORMED, + $this->validator->settlementFailureReason($response, $order, true) + ); + } - $this->assertNotEmpty($result); - foreach ($result as $error) { - $this->assertArrayHasKey('code', $error); - $this->assertArrayHasKey('message', $error); - } + public function testFractionalFloatAmountRejectsAsMalformed(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000.5]); + + $this->assertSame( + TransactionValidator::REASON_MALFORMED, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testAmountAtExactExpectedIsAccepted(): void + public function testNumericStringAmountAcceptedOnMatch(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 10000]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => '500000']); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } - $result = $this->validator->validate($verifyData, $order, true); + public function testZeroExpectedTotalRejects(): void + { + $order = $this->makeOrder(0.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000]); + + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame( + TransactionValidator::REASON_ZERO_TOTAL, + $this->validator->settlementFailureReason($response, $order, true) + ); + } + + public function testZeroPaidAmountRejects(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 0]); - $codes = array_column($result, 'code'); - $this->assertNotContains(TransactionValidator::AMOUNT_MISMATCH, $codes); - $this->assertNotContains(TransactionValidator::AMOUNT_EXCESS, $codes); + $this->assertSame( + TransactionValidator::REASON_ZERO_TOTAL, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testLegacyMathCeilOverpaymentOfOneSubunitIsAccepted(): void + public function testNullGrandTotalRejectsAsZeroTotal(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 10001]); + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); - $result = $this->validator->validate($verifyData, $order, true); + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn(null); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getPayment')->willReturn($payment); + $order->method('getIncrementId')->willReturn('000000001'); - $codes = array_column($result, 'code'); - $this->assertNotContains(TransactionValidator::AMOUNT_MISMATCH, $codes); - $this->assertNotContains(TransactionValidator::AMOUNT_EXCESS, $codes); + $response = $this->verifyResponse(['amount' => 500000]); + + $this->assertSame( + TransactionValidator::REASON_ZERO_TOTAL, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testAmountOneSubunitBelowExpectedIsRejected(): void + /** + * Non-numeric grand total (import/headless/partial-migration order + * shapes): expectedSubunits()'s is_numeric guard returns 0, caught by + * the existing REASON_ZERO_TOTAL check rather than a TypeError. + */ + public function testNonNumericGrandTotalRejectsAsZeroTotal(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 9999]); + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn('not-a-number'); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getPayment')->willReturn($payment); + $order->method('getIncrementId')->willReturn('000000001'); - $result = $this->validator->validate($verifyData, $order, true); + $response = $this->verifyResponse(['amount' => 500000]); - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::AMOUNT_MISMATCH, $codes); + $this->assertSame( + TransactionValidator::REASON_ZERO_TOTAL, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testAmountTwoSubunitsAboveExpectedIsRejectedAsExcess(): void + public function testMissingOrderCurrencyRejectsAsCurrencyMismatch(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 10002]); + $order = $this->makeOrder(5000.00, null); + $response = $this->verifyResponse(); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('warning'); - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::AMOUNT_EXCESS, $codes); - $this->assertNotContains(TransactionValidator::AMOUNT_MISMATCH, $codes); + $this->assertSame( + TransactionValidator::REASON_CURRENCY_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testStringGrandTotalDoesNotThrow(): void + public function testWrongPaymentMethodRejects(): void { - $order = $this->makeOrder('19.9900', 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 1999]); + $order = $this->makeOrder(5000.00, 'NGN', 'checkmo'); + $response = $this->verifyResponse(); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('warning'); - $this->assertSame([], $result); + $this->assertSame( + TransactionValidator::REASON_WRONG_METHOD, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testUsesOrderCurrencyCodeNotBaseCurrencyCode(): void + public function testNullPaymentRejectsAsWrongMethod(): void { - $order = $this->createMock(OrderInterface::class); - $order->method('getGrandTotal')->willReturn(100.00); - $order->method('getOrderCurrencyCode')->willReturn('USD'); - $order->expects($this->never())->method('getBaseCurrencyCode'); - $order->expects($this->never())->method('getBaseGrandTotal'); + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getPayment')->willReturn(null); + $order->method('getIncrementId')->willReturn('000000001'); + + $response = $this->verifyResponse(); + + $this->assertSame( + TransactionValidator::REASON_WRONG_METHOD, + $this->validator->settlementFailureReason($response, $order, true) + ); + } - $verifyData = $this->makeVerifyData(['currency' => 'USD']); + /** + * @dataProvider inFlightStatusProvider + */ + public function testInFlightStatusesReject(string $status): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['status' => $status]); + + $this->logger->expects($this->never())->method($this->anything()); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertSame( + TransactionValidator::REASON_IN_FLIGHT, + $this->validator->settlementFailureReason($response, $order, true) + ); + } - $this->assertSame([], $result); + public static function inFlightStatusProvider(): array + { + return [ + 'pending' => ['pending'], + 'ongoing' => ['ongoing'], + 'queued' => ['queued'], + ]; } - public function testCurrencyCaseDifferenceStillPasses(): void + /** + * @dataProvider notSuccessfulStatusProvider + */ + public function testNotSuccessfulStatusesReject(string $status): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['currency' => 'ngn']); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['status' => $status]); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->never())->method($this->anything()); - $this->assertSame([], $result); + $this->assertSame( + TransactionValidator::REASON_NOT_SUCCESSFUL, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testNullVerifyDataShortCircuitsWithoutThrowing(): void + public static function notSuccessfulStatusProvider(): array { - $order = $this->makeOrder(100.00, 'NGN'); + return [ + 'abandoned' => ['abandoned'], + 'failed' => ['failed'], + ]; + } - $result = $this->validator->validate(null, $order, true); + // --- domain / mode-mismatch -------------------------------------------- - $this->assertCount(1, $result); - $this->assertSame(TransactionValidator::STATUS_NOT_SUCCESS, $result[0]['code']); + public function testDomainMatchingTestModePasses(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => 'test']); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testMissingStatusPropertyShortCircuits(): void + public function testDomainMatchingLiveModePasses(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = (object) ['currency' => 'NGN', 'amount' => 10000, 'domain' => 'test']; + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => 'live']); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertNull($this->validator->settlementFailureReason($response, $order, false)); + } - $this->assertCount(1, $result); - $this->assertSame(TransactionValidator::STATUS_NOT_SUCCESS, $result[0]['code']); + public function testDomainLiveWhenTestModeExpectedRejectsAsModeMismatch(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => 'live']); + + $this->assertSame( + TransactionValidator::REASON_MODE_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); + } + + public function testDomainTestWhenLiveModeExpectedRejectsAsModeMismatch(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => 'test']); + + $this->assertSame( + TransactionValidator::REASON_MODE_MISMATCH, + $this->validator->settlementFailureReason($response, $order, false) + ); + } + + /** + * A missing/renamed `domain` must skip the mode check entirely (fail + * open) rather than reject the whole payment — `domain` only feeds the + * secondary mode-mismatch sanity signal, not the core settlement + * guarantee, unlike status/currency/paid amount. The skip is logged as a + * warning so it stays visible. + */ + public function testMissingDomainSkipsModeCheckAndLogsWarning(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => null]); + + $this->logger->expects($this->once())->method('warning'); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testMissingCurrencyPropertyFails(): void + public function testNonStringDomainSkipsModeCheckAndLogsWarning(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = (object) ['status' => 'success', 'amount' => 10000, 'domain' => 'test']; + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => ['test']]); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('warning'); - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::CURRENCY_MISMATCH, $codes); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testMissingAmountPropertyFails(): void + public function testEmptyStringDomainSkipsModeCheckAndLogsWarning(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = (object) ['status' => 'success', 'currency' => 'NGN', 'domain' => 'test']; + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['domain' => '']); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('warning'); - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::AMOUNT_MISMATCH, $codes); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testMissingDomainPropertyFails(): void + // --- requested_amount two-field overpayment check ----------------------- + + public function testRequestedAmountExactMatchWithFeeOnPaidPasses(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = (object) ['status' => 'success', 'currency' => 'NGN', 'amount' => 10000]; + $order = $this->makeOrder(5000.00, 'NGN'); + // expected 500000; requested matches exactly; paid includes an + // uncapped fee on top — accepted, any excess is fee. + $response = $this->verifyResponse(['amount' => 515000, 'requested_amount' => 500000]); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('info'); - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::MODE_MISMATCH, $codes); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testZeroGrandTotalIsRejectedNotVacuouslyPassed(): void + public function testRequestedAmountWithinToleranceWindowPasses(): void { - $order = $this->makeOrder(0, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 0]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500001, 'requested_amount' => 500001]); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::ORDER_AMOUNT_INVALID, $codes); + public function testRequestedAmountBeyondToleranceWindowRejects(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + // requested_amount is 100x the expected figure — the wrong-exponent + // client-bug scenario this design exists to catch, regardless of any + // fee added on top of it. + $response = $this->verifyResponse(['amount' => 50000000, 'requested_amount' => 50000000]); + + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public function testNullGrandTotalIsRejected(): void + public function testRequestedAmountBelowExpectedRejects(): void { - $order = $this->makeOrder(null, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 0]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 400000, 'requested_amount' => 400000]); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); + } - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::ORDER_AMOUNT_INVALID, $codes); + /** + * The tolerance window is symmetric: a requested_amount landing 1 subunit + * BELOW expected (plausible from independent client/server rounding of + * the same order total) is still accepted, same as 1 above. + */ + public function testRequestedAmountOneBelowExpectedWithinToleranceWindowPasses(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499999, 'requested_amount' => 499999]); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); } - public function testEmptyOrderCurrencyCodeIsRejected(): void + public function testRequestedAmountTwoBelowExpectedBeyondToleranceWindowRejects(): void { - $order = $this->makeOrder(100.00, ''); - $verifyData = $this->makeVerifyData(); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499998, 'requested_amount' => 499998]); - $result = $this->validator->validate($verifyData, $order, true); + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); + } - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::CURRENCY_MISMATCH, $codes); + public function testPaidLessThanRequestedAmountRejects(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + // requested_amount matches expected, but paid fell short of even that + // — the merchant was not paid what was requested. + $response = $this->verifyResponse(['amount' => 499000, 'requested_amount' => 500000]); + + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); } /** - * @dataProvider inFlightStatusProvider + * requested_amount present but negative: is-integral (is_int) still + * accepts it, so it is NOT treated as unreadable/absent — it must go + * through the tolerance-window comparison like any other integral value + * and lose there (a negative figure is always far outside + * `[expected, expected + TOLERANCE]`), not slip past as "absent -> fall + * back to the permissive paid >= expected check". */ - public function testInFlightStatusReturnsAwaitingConfirmationNotStatusNotSuccess(string $status): void + public function testNegativeRequestedAmountRejectsViaToleranceWindowNotFallback(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['status' => $status]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000, 'requested_amount' => -500000]); - $result = $this->validator->validate($verifyData, $order, true); + $this->logger->expects($this->once())->method('warning'); - $this->assertSame([TransactionValidator::STATUS_AWAITING_CONFIRMATION], array_column($result, 'code')); + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); } - public static function inFlightStatusProvider(): array + /** + * requested_amount present but a non-integral FLOAT (e.g. Paystack, or a + * proxy in front of it, returning `1999.5`): isIntegral() rejects it via + * the is_float branch specifically (distinct code path from the + * non-numeric-string case already covered by + * testRequestedAmountUnreadableFallsBackToPlainPaidCheck) — must still be + * treated as absent/unreadable and fall back to the plain paid >= + * expected check, not thrown, not treated as a match. + */ + public function testNonIntegralFloatRequestedAmountFallsBackToPlainPaidCheck(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000, 'requested_amount' => 1999.5]); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } + + /** + * requested_amount AND paid are both wildly below expected, in the same + * direction (e.g. a client that sent a naira figure where kobo was + * expected, 100x under) — distinct from + * testRequestedAmountBelowExpectedRejects (a modest, single-digit-percent + * shortfall) and from testPaidLessThanRequestedAmountRejects (where + * requested_amount matches expected exactly and only paid falls short). + * Confirms the tolerance-window check (the first of the two + * requested_amount guards) is what catches this, not the second + * (paid < requested) guard, since here neither figure is anywhere near + * `expected`. + */ + public function testRequestedAmountAndPaidBothWildlyBelowExpectedInSameDirectionRejects(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 5000, 'requested_amount' => 5000]); + + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); + } + + public function testRequestedAmountAbsentFallsBackToPlainPaidCheck(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + // No requested_amount on this channel; plain paid >= expected fallback + // still passes. + $response = $this->verifyResponse(['amount' => 500000]); + + $this->assertArrayNotHasKey('requested_amount', (array) $response->data); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } + + /** + * The fallback window (no requested_amount available) is symmetric too: a + * paid amount 1 subunit below expected is still accepted. + */ + public function testFallbackPaidOneBelowExpectedWithinToleranceWindowPasses(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499999]); + + $this->assertArrayNotHasKey('requested_amount', (array) $response->data); + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } + + public function testFallbackPaidTwoBelowExpectedBeyondToleranceWindowRejects(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499998]); + + $this->assertArrayNotHasKey('requested_amount', (array) $response->data); + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($response, $order, true) + ); + } + + public function testRequestedAmountUnreadableFallsBackToPlainPaidCheck(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + // requested_amount present but not integral -> treated as absent, + // falls back to the plain paid >= expected check. + $response = $this->verifyResponse(['amount' => 500000, 'requested_amount' => 'garbage']); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + } + + /** + * Pins the checked-in-this-method sequence: when more than one thing is + * wrong with a response/order pair, the FIRST failing check in the + * documented order must win, not whichever the implementation happens to + * reach last. A refactor that reorders the checks would silently change + * which REASON_* callers see (and, via isPermanentForWebhook()/ + * isTerminalForCustomer(), whether Paystack gets a retry and whether the + * customer is invited to pay again) without any single-condition test here + * catching it. + * + * @dataProvider orderingPrecedenceProvider + */ + public function testSettlementFailureReasonPrecedenceOrder( + float $grandTotal, + ?string $currencyCode, + string $method, + array $responseOverrides, + bool $testMode, + string $expectedReason + ): void { + $order = $this->makeOrder($grandTotal, $currencyCode, $method); + $response = $this->verifyResponse($responseOverrides); + + $this->assertSame( + $expectedReason, + $this->validator->settlementFailureReason($response, $order, $testMode) + ); + } + + public static function orderingPrecedenceProvider(): array { return [ - 'pending' => ['pending'], - 'ongoing' => ['ongoing'], - 'queued' => ['queued'], + // Unreadable amount (MALFORMED) must win over a bad status (NOT_SUCCESSFUL) + // — MALFORMED is checked before the status branch is even reached. + 'malformed amount beats not-successful status' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['status' => 'failed', 'amount' => null], + true, + TransactionValidator::REASON_MALFORMED, + ], + // An in-flight status is checked before the amount is compared, so a + // short amount on an in-flight transaction still reports IN_FLIGHT. + 'in-flight status beats amount mismatch' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['status' => 'pending', 'amount' => 499999], + true, + TransactionValidator::REASON_IN_FLIGHT, + ], + // Wrong payment method is checked before the zero-total guard. + 'wrong method beats zero total' => [ + 0.00, + 'NGN', + 'checkmo', + [], + true, + TransactionValidator::REASON_WRONG_METHOD, + ], + // Zero/negative expected total is checked before currency is compared. + 'zero total beats currency mismatch' => [ + 0.00, + 'NGN', + Paystack::CODE, + ['currency' => 'USD'], + true, + TransactionValidator::REASON_ZERO_TOTAL, + ], + // Currency mismatch is checked before the amount comparison. + 'currency mismatch beats amount mismatch' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['currency' => 'USD', 'amount' => 499999], + true, + TransactionValidator::REASON_CURRENCY_MISMATCH, + ], + // Non-success status is checked before a currency mismatch is even + // reached. + 'non-success beats currency mismatch' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['status' => 'failed', 'currency' => 'USD'], + true, + TransactionValidator::REASON_NOT_SUCCESSFUL, + ], + // The mode-mismatch check is positioned after every amount + // rejection path (the last guard before the overpayment info-log) + // specifically so an amount mismatch keeps winning here — this is + // the exact placement the panel's finding was about: putting the + // mode check any earlier would flip this case's winning reason + // from AMOUNT_MISMATCH (permanent, 200) to MODE_MISMATCH + // (transient, 503), silently changing webhook retry behavior. + 'mode mismatch does not beat amount mismatch (fallback branch)' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['amount' => 499998, 'domain' => 'live'], + true, + TransactionValidator::REASON_AMOUNT_MISMATCH, + ], + // Same precedence, but exercising the requested_amount branch + // (not just the fallback): a requested_amount far outside the + // tight tolerance window must still win over a would-be mode + // mismatch. + 'mode mismatch does not beat amount mismatch (requested_amount beyond window)' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['amount' => 50000000, 'requested_amount' => 50000000, 'domain' => 'live'], + true, + TransactionValidator::REASON_AMOUNT_MISMATCH, + ], + // Same precedence, but for the requested_amount branch's second + // condition (paid < requested) rather than the tolerance window. + 'mode mismatch does not beat amount mismatch (paid below requested_amount)' => [ + 5000.00, + 'NGN', + Paystack::CODE, + ['amount' => 499000, 'requested_amount' => 500000, 'domain' => 'live'], + true, + TransactionValidator::REASON_AMOUNT_MISMATCH, + ], ]; } - public function testGenuineDeclineStillReturnsStatusNotSuccess(): void + /** + * Pins the getGrandTotal() + getOrderCurrencyCode() pairing both init paths + * (Setup.php, the inline JS) use for the expected-amount comparison. A future + * swap to getBaseGrandTotal() (the base-currency total) must fail this test: + * a store with a USD base currency and an NGN order currency has a base grand + * total that does not correspond to the NGN amount Paystack actually charged. + */ + public function testBaseCurrencyDivergenceFromOrderCurrencyIsPinned(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['status' => 'failed']); + // Order-currency total: 25000.00 NGN -> 2,500,000 kobo, matches what + // Paystack (an NGN-denominated charge) reports as paid. + $order = $this->makeOrder(25000.00, 'NGN'); + // Stubbed so a future swap to getBaseGrandTotal() fails via the real + // amount-mismatch mechanism below, not via ZERO_TOTAL (an unstubbed + // mock method returns null, and a null base total would hit the + // zero-total guard first — passing this test for the wrong reason). + // 15000.00 mirrors the base-USD-derived figure the assertion below + // exercises (15000.00 * 100 = 1,500,000 subunits). + $order->method('getBaseGrandTotal')->willReturn(15000.00); + $response = $this->verifyResponse(['amount' => 2500000, 'currency' => 'NGN']); + + $this->assertNull($this->validator->settlementFailureReason($response, $order, true)); + + // Same order, but the verify amount now reflects what a base-USD-derived + // subunit figure (i.e. getBaseGrandTotal() * 100) would have been -- this + // must NOT match, because the validator reads getGrandTotal() (the + // order-currency total), not the base total. + $baseDerivedResponse = $this->verifyResponse(['amount' => 1500000, 'currency' => 'NGN']); + + $this->assertSame( + TransactionValidator::REASON_AMOUNT_MISMATCH, + $this->validator->settlementFailureReason($baseDerivedResponse, $order, true) + ); + } - $result = $this->validator->validate($verifyData, $order, true); + /** + * @dataProvider expectedSubunitsProvider + * @param float|string $grandTotal + */ + public function testExpectedSubunits($grandTotal, int $expected): void + { + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn($grandTotal); - $this->assertSame([TransactionValidator::STATUS_NOT_SUCCESS], array_column($result, 'code')); + $this->assertSame($expected, $this->validator->expectedSubunits($order)); } - public function testMissingTransactionAmountFieldIsAmountMismatchNotOrderAmountInvalid(): void + public static function expectedSubunitsProvider(): array { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => null]); + return [ + '19.99 -> 1999' => [19.99, 1999], + '8.21 -> 821' => [8.21, 821], + '5000.00 -> 500000' => [5000.00, 500000], + 'decimal string 20.1500 -> 2015' => ['20.1500', 2015], + ]; + } - $result = $this->validator->validate($verifyData, $order, true); + /** + * The is_numeric guard: a non-numeric grand total must return 0, never + * throw a TypeError. + */ + public function testExpectedSubunitsReturnsZeroForNonNumericGrandTotal(): void + { + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn('not-a-number'); + + $this->assertSame(0, $this->validator->expectedSubunits($order)); + } + + public function testExpectedSubunitsReturnsZeroForNullGrandTotal(): void + { + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn(null); - $this->assertSame([TransactionValidator::AMOUNT_MISMATCH], array_column($result, 'code')); + $this->assertSame(0, $this->validator->expectedSubunits($order)); } /** - * A genuinely garbage (non-numeric, non-null) amount must fail the - * is_numeric() check itself, distinct from the ?? null coalesce exercised - * by a missing/null amount property. + * @dataProvider retryableReasonProvider */ - public function testNonNumericStringAmountIsAmountMismatch(): void + public function testIsTerminalForCustomerFalseForRetryableReasons(string $reason): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => 'abc']); + $this->assertFalse($this->validator->isTerminalForCustomer($reason)); + } - $result = $this->validator->validate($verifyData, $order, true); + public static function retryableReasonProvider(): array + { + return [ + 'not_successful' => [TransactionValidator::REASON_NOT_SUCCESSFUL], + 'bad_reference' => [TransactionValidator::REASON_BAD_REFERENCE], + ]; + } - $this->assertSame([TransactionValidator::AMOUNT_MISMATCH], array_column($result, 'code')); + /** + * @dataProvider terminalReasonProvider + */ + public function testIsTerminalForCustomerTrueForEverythingElse(string $reason): void + { + $this->assertTrue($this->validator->isTerminalForCustomer($reason)); + } + + public static function terminalReasonProvider(): array + { + return [ + 'in_flight' => [TransactionValidator::REASON_IN_FLIGHT], + 'amount_mismatch' => [TransactionValidator::REASON_AMOUNT_MISMATCH], + 'currency_mismatch' => [TransactionValidator::REASON_CURRENCY_MISMATCH], + 'zero_total' => [TransactionValidator::REASON_ZERO_TOTAL], + 'wrong_method' => [TransactionValidator::REASON_WRONG_METHOD], + 'malformed' => [TransactionValidator::REASON_MALFORMED], + 'mode_mismatch' => [TransactionValidator::REASON_MODE_MISMATCH], + // Regression test for the whole finding: a reason this class does + // not (yet) know about must fail closed, not silently invite a + // second payment the way the three old, independent policy maps did. + 'unknown reason fails closed' => ['some_future_reason'], + ]; } /** - * Paystack's JSON responses are decoded server-side; a numeric string - * (e.g. "10000") is valid JSON-number input in disguise and must be - * accepted via PHP's numeric-string comparison, not rejected outright. + * @dataProvider permanentWebhookReasonProvider */ - public function testNumericStringAmountMatchingExpectedIsAccepted(): void + public function testIsPermanentForWebhookTrueForAllowListedReasons(string $reason): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['amount' => '10000']); + $this->assertTrue($this->validator->isPermanentForWebhook($reason)); + } - $result = $this->validator->validate($verifyData, $order, true); + public static function permanentWebhookReasonProvider(): array + { + return [ + 'not_successful' => [TransactionValidator::REASON_NOT_SUCCESSFUL], + 'amount_mismatch' => [TransactionValidator::REASON_AMOUNT_MISMATCH], + 'currency_mismatch' => [TransactionValidator::REASON_CURRENCY_MISMATCH], + 'zero_total' => [TransactionValidator::REASON_ZERO_TOTAL], + ]; + } - $this->assertSame([], $result); + /** + * @dataProvider transientWebhookReasonProvider + */ + public function testIsPermanentForWebhookFalseForEverythingElse(string $reason): void + { + $this->assertFalse($this->validator->isPermanentForWebhook($reason)); } - public function testNonScalarCurrencyDoesNotThrowAndFailsClosed(): void + public static function transientWebhookReasonProvider(): array { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['currency' => new \stdClass()]); + return [ + 'in_flight' => [TransactionValidator::REASON_IN_FLIGHT], + 'malformed' => [TransactionValidator::REASON_MALFORMED], + 'wrong_method' => [TransactionValidator::REASON_WRONG_METHOD], + 'bad_reference' => [TransactionValidator::REASON_BAD_REFERENCE], + // REASON_MODE_MISMATCH is deliberately NOT permanent: a swapped/ + // misconfigured secret-key pair is not "the money definitively + // didn't settle" the same way a real amount/currency mismatch is. + 'mode_mismatch' => [TransactionValidator::REASON_MODE_MISMATCH], + // Regression test for the whole finding: an unrecognised reason must + // default to transient (retry), not permanent — a permanent 200 on a + // reason we cannot classify would silently strand a real payment + // with no retry and no merchant trace. + 'unknown reason fails closed (transient)' => ['some_future_reason'], + ]; + } - $result = $this->validator->validate($verifyData, $order, true); + /** + * @dataProvider customerMessageProvider + */ + public function testCustomerMessage(string $reason, string $expectedMessage): void + { + $this->assertSame($expectedMessage, $this->validator->customerMessage($reason)); + } + + public static function customerMessageProvider(): array + { + $doNotPayAgain = "We could not confirm your payment. Please do not pay " + . "again — contact support with your order number."; + + return [ + 'not_successful' => [ + TransactionValidator::REASON_NOT_SUCCESSFUL, + "Your payment was not completed. Please try again.", + ], + 'bad_reference' => [ + TransactionValidator::REASON_BAD_REFERENCE, + "Payment could not be verified. Please try again.", + ], + 'in_flight' => [ + TransactionValidator::REASON_IN_FLIGHT, + "Your payment is still being confirmed. Please do not pay " + . "again — we will email you once it is confirmed.", + ], + 'amount_mismatch' => [TransactionValidator::REASON_AMOUNT_MISMATCH, $doNotPayAgain], + 'currency_mismatch' => [TransactionValidator::REASON_CURRENCY_MISMATCH, $doNotPayAgain], + 'zero_total' => [TransactionValidator::REASON_ZERO_TOTAL, $doNotPayAgain], + 'wrong_method' => [TransactionValidator::REASON_WRONG_METHOD, $doNotPayAgain], + 'malformed' => [TransactionValidator::REASON_MALFORMED, $doNotPayAgain], + 'mode_mismatch' => [TransactionValidator::REASON_MODE_MISMATCH, $doNotPayAgain], + // Regression test for the whole finding: an unrecognised reason must + // fall into the safest copy, exactly like isTerminalForCustomer() + // fails closed on terminality for the same input. + 'unknown reason fails closed' => ['some_future_reason', $doNotPayAgain], + ]; + } + + public function testIsOverpaymentTrueWhenPaidExceedsExpected(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500001]); + + $this->assertTrue($this->validator->isOverpayment($response, $order)); + } + + public function testIsOverpaymentFalseOnExactMatch(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000]); + + $this->assertFalse($this->validator->isOverpayment($response, $order)); + } + + public function testIsOverpaymentFalseWhenShort(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 499999]); + + $this->assertFalse($this->validator->isOverpayment($response, $order)); + } + + /** + * When requested_amount is readable, isOverpayment() compares against it + * (not expectedSubunits()) for consistency with the two-field check in + * settlementFailureReason(). + */ + public function testIsOverpaymentComparesAgainstRequestedAmountWhenPresent(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 515000, 'requested_amount' => 500000]); + + $this->assertTrue($this->validator->isOverpayment($response, $order)); + } - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::CURRENCY_MISMATCH, $codes); + public function testIsOverpaymentFalseWhenPaidMatchesRequestedAmountExactly(): void + { + $order = $this->makeOrder(5000.00, 'NGN'); + $response = $this->verifyResponse(['amount' => 500000, 'requested_amount' => 500000]); + + $this->assertFalse($this->validator->isOverpayment($response, $order)); } - public function testArrayCurrencyDoesNotThrowAndFailsClosed(): void + /** + * A read, not a gate: an unreadable envelope must not throw, it must just + * answer "no". + */ + public function testIsOverpaymentFalseOnUnreadableResponse(): void { - $order = $this->makeOrder(100.00, 'NGN'); - $verifyData = $this->makeVerifyData(['currency' => ['NGN']]); + $order = $this->makeOrder(5000.00, 'NGN'); + $response = (object) []; + + $this->assertFalse($this->validator->isOverpayment($response, $order)); + } + + public function testPaidSubunitsReturnsIntOnValidResponse(): void + { + $response = $this->verifyResponse(['amount' => 500000]); + + $this->assertSame(500000, $this->validator->paidSubunits($response)); + } + + public function testPaidSubunitsNullOnMissingData(): void + { + $response = (object) []; + + $this->assertNull($this->validator->paidSubunits($response)); + } - $result = $this->validator->validate($verifyData, $order, true); + public function testPaidSubunitsNullOnFractionalAmount(): void + { + $response = $this->verifyResponse(['amount' => 500000.5]); - $codes = array_column($result, 'code'); - $this->assertContains(TransactionValidator::CURRENCY_MISMATCH, $codes); + $this->assertNull($this->validator->paidSubunits($response)); } - public function testChargeIsRealTrueOnlyWhenSuccessAndLiveDomain(): void + /** + * Pins the contract that 0 is a valid, readable amount, distinct from + * "unreadable" (null) — a caller checking falsiness instead of `=== null` + * would conflate the two. + */ + public function testPaidSubunitsReturnsZeroForZeroAmount(): void { - $verifyData = $this->makeVerifyData(['status' => 'success', 'domain' => 'live']); + $response = $this->verifyResponse(['amount' => 0]); - $this->assertTrue(TransactionValidator::chargeIsReal($verifyData)); + $this->assertSame(0, $this->validator->paidSubunits($response)); } - public function testChargeIsRealFalseForSuccessOnTestDomain(): void + public function testIsPaystackOrderTrueForPaystackMethod(): void { - $verifyData = $this->makeVerifyData(['status' => 'success', 'domain' => 'test']); + $order = $this->makeOrder(5000.00, 'NGN', Paystack::CODE); - $this->assertFalse(TransactionValidator::chargeIsReal($verifyData)); + $this->assertTrue($this->validator->isPaystackOrder($order)); } - public function testChargeIsRealFalseForNonSuccessStatus(): void + public function testIsPaystackOrderFalseForOtherMethod(): void { - $verifyData = $this->makeVerifyData(['status' => 'failed', 'domain' => 'live']); + $order = $this->makeOrder(5000.00, 'NGN', 'checkmo'); - $this->assertFalse(TransactionValidator::chargeIsReal($verifyData)); + $this->assertFalse($this->validator->isPaystackOrder($order)); } - public function testChargeIsRealFalseForNullVerifyData(): void + public function testIsPaystackOrderFalseForNullPayment(): void { - $this->assertFalse(TransactionValidator::chargeIsReal(null)); + $order = $this->createMock(Order::class); + $order->method('getPayment')->willReturn(null); + + $this->assertFalse($this->validator->isPaystackOrder($order)); } - public function testChargeIsRealFalseWhenDomainMissing(): void + /** + * Drift-catcher: every REASON_* constant on this class must be named in + * one of the explicit/defaulted allow-lists below for each classification + * map. A new REASON_* nobody wires up here would otherwise silently + * inherit isTerminalForCustomer()'s and isPermanentForWebhook()'s + * fail-closed defaults and customerMessage()'s default copy without + * anyone deciding that was the intended classification — this test forces + * that decision to be explicit and named. + */ + public function testEveryReasonConstantIsAccountedForInClassificationMaps(): void { - $verifyData = (object) ['status' => 'success']; + $reflection = new \ReflectionClass(TransactionValidator::class); + $reasonConstants = array_filter( + $reflection->getConstants(), + static fn (string $name): bool => strpos($name, 'REASON_') === 0, + ARRAY_FILTER_USE_KEY + ); + + // isTerminalForCustomer(): explicit -> RETRYABLE_FOR_CUSTOMER (false); + // defaulted -> everything else, fails closed to true. + $terminalAccountedFor = [ + TransactionValidator::REASON_NOT_SUCCESSFUL, + TransactionValidator::REASON_BAD_REFERENCE, + TransactionValidator::REASON_MALFORMED, + TransactionValidator::REASON_IN_FLIGHT, + TransactionValidator::REASON_WRONG_METHOD, + TransactionValidator::REASON_ZERO_TOTAL, + TransactionValidator::REASON_CURRENCY_MISMATCH, + TransactionValidator::REASON_AMOUNT_MISMATCH, + TransactionValidator::REASON_MODE_MISMATCH, + ]; + + // isPermanentForWebhook(): explicit -> PERMANENT_FOR_WEBHOOK (true); + // defaulted -> everything else, fails closed to false (transient/retry). + $permanentAccountedFor = [ + TransactionValidator::REASON_NOT_SUCCESSFUL, + TransactionValidator::REASON_AMOUNT_MISMATCH, + TransactionValidator::REASON_CURRENCY_MISMATCH, + TransactionValidator::REASON_ZERO_TOTAL, + TransactionValidator::REASON_MALFORMED, + TransactionValidator::REASON_IN_FLIGHT, + TransactionValidator::REASON_WRONG_METHOD, + TransactionValidator::REASON_BAD_REFERENCE, + TransactionValidator::REASON_MODE_MISMATCH, + ]; - $this->assertFalse(TransactionValidator::chargeIsReal($verifyData)); + // customerMessage(): explicit -> its own switch case; defaulted -> the + // "do not pay again, contact support" fallback branch. + $messageAccountedFor = [ + TransactionValidator::REASON_NOT_SUCCESSFUL, + TransactionValidator::REASON_BAD_REFERENCE, + TransactionValidator::REASON_IN_FLIGHT, + TransactionValidator::REASON_AMOUNT_MISMATCH, + TransactionValidator::REASON_CURRENCY_MISMATCH, + TransactionValidator::REASON_ZERO_TOTAL, + TransactionValidator::REASON_WRONG_METHOD, + TransactionValidator::REASON_MALFORMED, + TransactionValidator::REASON_MODE_MISMATCH, + ]; + + foreach ($reasonConstants as $name => $value) { + $this->assertContains( + $value, + $terminalAccountedFor, + "$name ('$value') is not accounted for in isTerminalForCustomer()'s " + . "explicit-or-defaulted allow-list — decide and register it." + ); + $this->assertContains( + $value, + $permanentAccountedFor, + "$name ('$value') is not accounted for in isPermanentForWebhook()'s " + . "explicit-or-defaulted allow-list — decide and register it." + ); + $this->assertContains( + $value, + $messageAccountedFor, + "$name ('$value') is not accounted for in customerMessage()'s " + . "explicit-or-defaulted allow-list — decide and register it." + ); + } } } From e5e3989a51b217ddc63e06c3ca8b464169c37f37 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Tue, 29 Sep 2026 11:17:55 +0200 Subject: [PATCH 2/3] feat: port settlement-gate wiring into all three consumers, fix D12 (Item B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the reconciled TransactionValidator into Callback.php, Webhook.php, and PaymentManagement.php as a hard settlement gate (status, amount via the requested/paid two-field check, currency, method, domain), replacing duplicated ad hoc checks. Adds the webhook's transient-vs-permanent HTTP retry semantics, a Recreate.php guard against cancelling a settled or non-Paystack order, and closes the inline REST endpoint's internal-detail leak. Fixes D12 (paystack_payment_verify_after was only registered in etc/frontend/events.xml, never webapi_rest, so inline orders never advanced) and the webhook HMAC empty-secret-key fail-open. Both fixes were gated behind a 2-critic adversarial review that caught a real regression in the first D12 attempt: registering the event observer without its sibling suppression observer would have caused a duplicate confirmation email on every inline order (the module's default integration type) — fixed by mirroring both observer registrations in etc/webapi_rest/events.xml, matching etc/frontend/events.xml exactly. Also closed: a missing $dispatched guard on PaymentManagement's event dispatch (an observer throw after a real settlement would have shown "payment failed" for a paid order), and MODE_MISMATCH/WRONG_METHOD silently converting from retryable to permanently-dropped after the webhook's 15-minute recency window, contradicting their own classification as non-permanent. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 92 ++ CLAUDE.md | 2 +- Controller/Payment/Callback.php | 63 +- Controller/Payment/Recreate.php | 48 +- Controller/Payment/Webhook.php | 357 ++++- Gateway/PaystackApiClient.php | 5 + Model/PaymentManagement.php | 131 +- Test/Unit/Controller/Payment/CallbackTest.php | 212 ++- Test/Unit/Controller/Payment/RecreateTest.php | 136 +- Test/Unit/Controller/Payment/WebhookTest.php | 1190 ++++++++++++++++- Test/Unit/Gateway/PaystackApiClientTest.php | 44 + Test/Unit/Model/PaymentManagementTest.php | 312 ++++- etc/webapi_rest/events.xml | 9 + marketplace/src/reference-manual.md | 43 +- .../method-renderer/pstk_paystack-method.js | 70 +- 15 files changed, 2559 insertions(+), 155 deletions(-) create mode 100644 etc/webapi_rest/events.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 48113a6..294a8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,98 @@ This project adheres to [Semantic Versioning](https://semver.org/). The entries below cover every release since the last tag, **v3.0.10**. +## [Unreleased] + +Every payment-verification path now confirms, from Paystack's verify response, +that the transaction actually settles the order before the order is advanced +to Processing: transaction status must be `success`, the paid amount must +cover the order total (in subunits), the currency must match the order's +currency, the order must have been placed with the Paystack payment method, +and the response's live/test `domain` must match the store's configured mode. +Overpaying is accepted — normal when the customer bears Paystack's transaction +fee — and recorded; where a verify response also reports the amount actually +*requested* at initialize, that figure is checked against a tight window +around the order's expected total (catching a wrong-exponent client bug +regardless of any fee added on top), and the paid amount only has to cover +what was requested. + +### Security +- **A transaction that did not pay for an order can no longer advance it.** + Previously, the inline (popup) REST verification endpoint never checked the + verify response's status, and no path compared the paid amount or currency to + the order — a smaller or differently-denominated payment could mark an order + as Processing. All three paths (redirect callback, inline REST endpoint, + webhook) now share one settlement check (`Gateway/Validator/TransactionValidator`). +- **`/paystack/payment/recreate` no longer cancels a paid order.** The route + only acts on orders still in the `new` or `pending_payment` state, and only + when the order was placed with the Paystack payment method; a + processing/complete order, or one paid via another method, can no longer + have its quote restored by an anonymous GET. (Side effect: an + already-cancelled order no longer re-triggers a quote restore — the first + call has already restored the quote.) +- **The inline verification endpoint no longer leaks internal detail.** The + success response now returns only the transaction status and reference + (previously the full transaction object, including card BIN/last4, customer + email/phone, and IP, went to the browser), and error responses return a fixed + message instead of raw gateway/cURL text. +- **The webhook's HMAC signature check no longer fails open on an + unconfigured secret key.** An empty Paystack secret key (a store never + configured, or misconfigured for the active mode) made the signature check + trivially satisfiable by anyone, since `hash_hmac()` against an empty key is + computable without knowing any secret. The check now rejects outright when + no secret key is configured. +- **Both payment-verification observers are now also registered in the + `webapi_rest` area, not only `frontend`.** Previously + `etc/webapi_rest/events.xml` did not exist at all: the + `paystack_payment_verify_after` event (advances the order past pending and + sends the post-payment confirmation email) and `sales_order_place_before` + (suppresses the initial placement email until payment verifies) were only + wired in `etc/frontend/events.xml`. The inline flow's own REST endpoint + (`Model/PaymentManagement.php`) runs in the `webapi_rest` area, so neither + observer fired there — the module's default integration type sent the + placement confirmation email (that was never suppressed) but never + advanced the order or sent the post-payment confirmation. Registering only + `paystack_payment_verify_after` without also registering + `ObserverBeforeSalesOrderPlace` would have caused a duplicate email (the + unsuppressed placement email, plus a new post-payment one); both are + registered together. + +### Fixed +- **Webhook responses now distinguish transient from permanent failures.** + Transient conditions (transaction still settling via bank transfer/USSD, + Paystack API errors, order not yet found) return HTTP 503 so Paystack retries + within its ~72h window — previously every outcome returned HTTP 200, which + silently cancelled retries and could permanently strand a legitimate payment's + confirmation. Genuine rejections (failed status, amount/currency mismatch) + return HTTP 200 so Paystack does not pointlessly retry. +- **Rejected and surplus payments are now visible to the merchant.** When the + (signature-verified) webhook rejects a settlement mismatch, or accepts an + overpayment, it writes an order status-history comment with the paid vs + expected amount and reference. +- **A malformed inline verification reference no longer causes a 500** on the + anonymous REST route. +- **After a payment fails post-charge on the inline flow, the Place Order + button is no longer re-enabled** — re-enabling it invited a double charge + while money was already moving. +- **`Controller/Payment/Recreate.php` no longer calls the deprecated + `Order::save()`.** Order cancellation is now persisted through + `OrderRepositoryInterface::save()`. + +### Known limitations, not addressed by this change +- The webhook's fallback lookup of an order by `quote_id` + (`Controller/Payment/Webhook.php`, used when the popup flow's + Paystack-generated reference has no matching order) still resolves an + ambiguous match via `getTotalCount() == 1`/`getFirstItem()` rather than + disambiguating by amount. This is a separate, still-open issue (tracked as + D9/R2.8) — not fixed by this settlement-gate work, which only changed the + webhook's *retry* semantics (transient vs. permanent), not its order-lookup + logic. + +### Upgrade note +If a store's checkout was relying (unknowingly) on under- or mis-paid +transactions being accepted, those orders now stay pending and the webhook +records the mismatch in the order history. No configuration change is needed. + ## [3.0.11] - 2026-08-17 Corrects the transaction payload sent to Paystack: the amount is now always an diff --git a/CLAUDE.md b/CLAUDE.md index 953a22f..9bedd47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ There are two integration types, selectable in admin config: - Validates HMAC-SHA512 signature, verifies transaction, dispatches `paystack_payment_verify_after` - CSRF validation skipped via `Plugin/CsrfValidatorSkip.php` -The custom event `paystack_payment_verify_after` is the single point where order status is updated to Processing and confirmation email is sent (`Observer/ObserverAfterPaymentVerify.php`). Initial order confirmation email is suppressed by `ObserverBeforeSalesOrderPlace` until payment is verified. +The custom event `paystack_payment_verify_after` is the single point where order status is updated to Processing and confirmation email is sent (`Observer/ObserverAfterPaymentVerify.php`). Initial order confirmation email is suppressed by `ObserverBeforeSalesOrderPlace` until payment is verified. Both observers must be registered in **every** DI area a checkout path can place/verify an order from — `Model/PaymentManagement.php` (the inline flow's default integration type) runs in `webapi_rest`, not `frontend`, so both `etc/frontend/events.xml` and `etc/webapi_rest/events.xml` register both events; registering only one observer in `webapi_rest` without the other caused either a missing post-payment confirmation (inline orders stuck unadvanced) or a duplicate confirmation email (placement email unsuppressed, post-payment email also sent). ### Key Classes diff --git a/Controller/Payment/Callback.php b/Controller/Payment/Callback.php index 03fdb51..2036fc6 100644 --- a/Controller/Payment/Callback.php +++ b/Controller/Payment/Callback.php @@ -3,25 +3,27 @@ /** * Paystack Magento2 Module using \Magento\Payment\Model\Method\AbstractMethod * Copyright (C) 2019 Paystack.com - * + * * This file is part of Pstk/Paystack. - * + * * Pstk/Paystack is free software => you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see //www.gnu.org/licenses/>. */ namespace Pstk\Paystack\Controller\Payment; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; + class Callback extends AbstractPaystackStandard { /** @@ -40,6 +42,10 @@ public function execute() { // A rejection must not tell the customer to try again once money may have // moved. This is the one generic surface used by every failure branch. + // + // Deliberately not TransactionValidator::customerMessage(): the wording + // here (e.g. "before trying again" vs "Please try again."/"do not pay + // again") is a separate copy pinned by CallbackTest. $unconfirmed = "We could not confirm your payment. If you believe you were " . "charged, please contact support before trying again."; @@ -68,7 +74,13 @@ public function execute() { // transfer and USSD sit there at callback time and settle minutes // later via the webhook. Telling that customer to try again invites a // second payment for a charge already on its way. - $inFlight = in_array($status, ["pending", "ongoing", "queued"], true); + // + // Deliberately not TransactionValidator::customerMessage(): the + // wording below ("once it completes" / "before trying again") is a + // separate copy from customerMessage()'s IN_FLIGHT/NOT_SUCCESSFUL + // text ("once it is confirmed" / "Please try again."), pinned by + // CallbackTest. + $inFlight = in_array($status, TransactionValidator::STATUSES_IN_FLIGHT, true); return $this->redirectToFinal( false, @@ -82,12 +94,47 @@ public function execute() { $reference = explode('_', $transactionDetails->data->reference, 2); $reference = ($reference[0])?: 0; - + $order = $this->orderInterface->loadByIncrementId($reference); - + if ($order && $reference === $order->getIncrementId()) { + // The status gate above only confirms `success`; it says nothing about + // whether the amount/currency paid actually settles this order, or + // whether the order was even placed with Paystack. That double coverage + // is deliberate — this is the one check that closes those gaps, and it + // must run before $dispatched is set so a mismatch never reaches the + // "payment received" warning branch below. + $failureReason = $this->transactionValidator->settlementFailureReason( + $transactionDetails, + $order, + $this->paystackClient->isTestMode() + ); + + if (null !== $failureReason) { + $this->logger->warning( + 'Paystack callback rejected: settlement check failed', + [ + "reason" => $failureReason, + "reference" => $requestedReference, + "order_increment_id" => $order->getIncrementId(), + ] + ); + + // The old hardcoded copy here ("was not completed... contact + // support before trying again") was factually wrong for + // REASON_AMOUNT_MISMATCH: the payment did complete, it just did + // not cover the order, so telling the customer nothing was + // charged was false and the "try again" invitation risked a + // second charge. customerMessage() is the single owner of this + // copy now, shared with PaymentManagement's inline path. + return $this->redirectToFinal( + false, + $this->transactionValidator->customerMessage($failureReason) + ); + } + // dispatch the `payment_verify_after` event to update the order status - + $dispatched = true; $this->eventManager->dispatch('paystack_payment_verify_after', [ diff --git a/Controller/Payment/Recreate.php b/Controller/Payment/Recreate.php index 9fe74f9..3a762f5 100644 --- a/Controller/Payment/Recreate.php +++ b/Controller/Payment/Recreate.php @@ -3,19 +3,19 @@ /** * Paystack Magento2 Module using \Magento\Payment\Model\Method\AbstractMethod * Copyright (C) 2019 Paystack.com - * + * * This file is part of Pstk/Paystack. - * + * * Pstk/Paystack is free software => you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see //www.gnu.org/licenses/>. */ @@ -27,13 +27,43 @@ class Recreate extends AbstractPaystackStandard { public function execute() { - + $order = $this->checkoutSession->getLastRealOrder(); - if ($order->getId() && $order->getState() != Order::STATE_CANCELED) { - $order->registerCancellation("Payment failed or cancelled")->save(); - + + // No last real order at all is a benign repeat hit — double-submit, back + // button, refresh, or an expired session — not an incident. Just send the + // customer back to checkout. + if (!$order->getId()) { + return $this->_redirect('checkout', ['_fragment' => 'payment']); + } + + // A GET to this route is anonymous and unauthenticated. These two guards + // stop an anonymous GET from cancelling a settled/advanced order (or one + // paid via a non-Paystack method) and restoring its quote. Neither makes a + // Paystack reference single-use: a customer can pay, close the tab before + // verify runs, and the order stays "new" with the paid reference still + // replayable — closing that gap is the deferred R2.3 reference-consumption + // work. + + // Allow-list, not deny-list: only the two pre-payment states are + // restorable, so a future state this list doesn't know about fails closed. + $isPrePaymentState = in_array( + $order->getState(), + [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT], + true + ); + + if (!$isPrePaymentState || !$this->transactionValidator->isPaystackOrder($order)) { + return $this->redirectToFinal( + false, + "We could not restart this payment. Please contact support if you " + . "believe this is an error." + ); } - + + $order->registerCancellation("Payment failed or cancelled"); + $this->orderRepository->save($order); + $this->checkoutSession->restoreQuote(); $this->_redirect('checkout', ['_fragment' => 'payment']); } diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index f01b80f..0c32e94 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -22,18 +22,42 @@ namespace Pstk\Paystack\Controller\Payment; +use Magento\Sales\Api\Data\OrderInterface; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; + class Webhook extends AbstractPaystackStandard { + /** + * How long after a transaction's paid_at/created_at an order-not-found result + * stays transient (503, Paystack retries). Beyond this window it becomes + * permanent (200): most charge.success events with no matching order will + * never get one (Payment Pages, dashboard charges, subscription renewals, + * another integration on the same Paystack account), and 503-ing those for + * Paystack's full ~72h retry budget is what makes Paystack back off or + * disable the endpoint, losing legitimate confirmations elsewhere. + */ + private const ORDER_LOOKUP_RETRY_WINDOW_SECONDS = 900; - public function execute() { - $finalMessage = "failed"; + /** + * Reasons deliberately excluded from TransactionValidator::PERMANENT_FOR_WEBHOOK + * (see its own comments) that must also never fall back to permanent once + * ORDER_LOOKUP_RETRY_WINDOW_SECONDS elapses — a swapped key pair or a wrong + * payment method on this order is never fixed by the passage of time, so + * unlike MALFORMED/unrecognised reasons (which do fall back to permanent + * below), these two keep retrying (503) indefinitely, the same treatment + * REASON_IN_FLIGHT gets. + */ + private const NEVER_RECENCY_BOUNDED = [ + TransactionValidator::REASON_MODE_MISMATCH, + TransactionValidator::REASON_WRONG_METHOD, + ]; - $resultFactory = $this->resultFactory->create(\Magento\Framework\Controller\ResultFactory::TYPE_RAW); + public function execute() { + $result = $this->resultFactory->create(\Magento\Framework\Controller\ResultFactory::TYPE_RAW); try { // Retrieve the request's body and parse it as JSON $rawBody = $this->request->getContent(); - http_response_code(200); $this->logger->info("Paystack Webhook: received request"); @@ -41,29 +65,44 @@ public function execute() { $signature = $this->request->getHeader('X-Paystack-Signature') ?: ''; if (!$signature || !$this->paystackClient->validateWebhookSignature($rawBody, $signature)) { $this->logger->warning("Paystack Webhook: signature validation failed"); - $resultFactory->setContents("auth failed"); - return $resultFactory; + return $this->respond($result, 401, "auth failed"); } $this->logger->info("Paystack Webhook: signature valid"); $event = json_decode($rawBody); if (!$event) { - $resultFactory->setContents("invalid payload"); - return $resultFactory; + // Malformed JSON cannot be fixed by a retry — permanent, not 503. + return $this->respond($result, 200, "invalid payload"); } - $this->logger->info("Paystack Webhook: event type = " . ($event->event ?? 'unknown')); + $eventType = $event->event ?? null; + $this->logger->info("Paystack Webhook: event type = " . ($eventType ?? 'unknown')); - switch ($event->event) { + switch ($eventType) { case 'charge.success': - if ('success' === $event->data->status) { - $transactionDetails = $this->paystackClient->verifyTransaction($event->data->reference); + $eventStatus = $event->data->status ?? null; + if ('success' === $eventStatus) { + $eventReference = $event->data->reference ?? null; + if (!is_string($eventReference) || $eventReference === '') { + // A signed payload with no reference to verify cannot be + // fixed by a retry — Paystack will resend the same body. + $this->logger->warning("Paystack Webhook: signed payload missing data.reference"); + return $this->respond($result, 200, "invalid payload"); + } - $reference = $transactionDetails->data->reference; - $this->logger->info("Paystack Webhook: verified transaction", ['reference' => $reference]); + $transactionDetails = $this->paystackClient->verifyTransaction($eventReference); + + $reference = $transactionDetails->data->reference ?? null; + if (!is_string($reference) || $reference === '') { + // The verify response itself is unreadable — that is the + // gateway's answer, not our payload, so a retry may get a + // readable one. Transient, matching REASON_MALFORMED below. + $this->logger->warning("Paystack Webhook: verify response missing data.reference", ['event_reference' => $eventReference]); + return $this->respond($result, 503, "unverified"); + } - $this->paystackClient->logTransactionSuccess($reference, $this->configProvider->getPublicKey()); + $this->logger->info("Paystack Webhook: verified transaction", ['reference' => $reference]); $order = $this->orderInterface->loadByIncrementId($reference); @@ -80,28 +119,284 @@ public function execute() { } if ($order && $order->getId()) { - $this->logger->info("Paystack Webhook: order found, dispatching verify event", [ - 'order_id' => $order->getIncrementId(), - 'current_status' => $order->getStatus(), - ]); - // dispatch the `payment_verify_after` event to update the order status - $this->eventManager->dispatch('paystack_payment_verify_after', [ - "paystack_order" => $order, - ]); - - $resultFactory->setContents("success"); - return $resultFactory; + [$code, $body] = $this->resolveSettlement($transactionDetails, $order, $reference); + return $this->respond($result, $code, $body); } - $this->logger->warning("Paystack Webhook: order not found for reference " . $reference); + + [$code, $body] = $this->resolveOrderNotFound($transactionDetails, $reference); + return $this->respond($result, $code, $body); } break; } - } catch (\Exception $exc) { + } catch (\Pstk\Paystack\Gateway\Exception\ApiException $e) { + // ApiException means Paystack itself answered with a permanent + // gateway rejection (4xx status, body.status === false — e.g. + // "Transaction reference not found", an auth failure), not a + // transient network problem — but that is indistinguishable here + // from a genuine timeout without some recency signal. No verify + // response exists at this catch site (verifyTransaction() itself + // threw before returning one), so the only available signal is + // the webhook payload's own paid_at/created_at: $event already + // has the same shape (->data->paid_at) isRecentTransaction() + // reads, so it is reused as-is rather than inventing a new one. + $this->logger->error("Paystack Webhook: gateway rejected verify call", ['error' => $e->getMessage()]); + + if ($this->isRecentTransaction($event)) { + return $this->respond($result, 503, "unverified"); + } + + return $this->respond($result, 200, "unverified"); + } catch (\Throwable $exc) { + // \Throwable, not \Exception: a TypeError (e.g. verifyTransaction()'s + // non-nullable param) must not escape uncaught. Default transient so + // Paystack retries; the message stays out of the response body since + // it can carry curl_error()/gateway detail. $this->logger->error("Paystack Webhook: exception", ['error' => $exc->getMessage()]); - $finalMessage = $exc->getMessage(); + return $this->respond($result, 503, "error"); + } + + // Every event type this switch does not handle, and a charge.success + // whose payload status is not itself "success", falls through to here. + // Neither is an error — Paystack sends many event types this endpoint + // does not act on — so it is permanently accepted (200), never retried. + return $this->respond($result, 200, "ignored"); + } + + /** + * Sets $result's code/body and returns it. Mutates the one Raw instance + * execute() created rather than building a new result, so every exit + * point shares it. + * + * @param \Magento\Framework\Controller\Result\Raw $result + * @param int $code + * @param string $body + * @return \Magento\Framework\Controller\Result\Raw + */ + private function respond($result, int $code, string $body) + { + $result->setHttpResponseCode($code); + $result->setContents($body); + return $result; + } + + /** + * Decides the webhook response for a matched order: re-verifies settlement, + * dispatches and logs the surplus on success, records merchant-visible + * history on rejection. Needs no result object — a pure decision function. + * + * @param object $transactionDetails Full envelope PaystackApiClient::verifyTransaction() returns + * @param OrderInterface $order + * @param string $reference + * @return array{0: int, 1: string} + */ + private function resolveSettlement(object $transactionDetails, OrderInterface $order, string $reference): array + { + // The webhook payload's own status only got us this far; the + // re-verified response is the trustworthy statement of what + // actually happened to the money — nothing may advance the + // order until it settles this order, in this currency, for at + // least this amount. + $reason = $this->transactionValidator->settlementFailureReason( + $transactionDetails, + $order, + $this->paystackClient->isTestMode() + ); + $expectedSubunits = $this->transactionValidator->expectedSubunits($order); + + if (null === $reason) { + $this->logger->info("Paystack Webhook: order found, dispatching verify event", [ + 'order_id' => $order->getIncrementId(), + 'current_status' => $order->getStatus(), + ]); + + // Only reported to Paystack's plugin tracker once the settlement + // gate has actually passed — logging it earlier told Paystack a + // charge succeeded even for rejected settlements. + $this->paystackClient->logTransactionSuccess($reference, $this->configProvider->getPublicKey()); + + if ($this->transactionValidator->isOverpayment($transactionDetails, $order)) { + // isOverpayment() returning true guarantees paidSubunits() is non-null. + $paidAmount = $this->transactionValidator->paidSubunits($transactionDetails); + $this->recordHistory( + $order, + $reference, + 'overpaid', + sprintf( + 'Paystack: payment overpaid — paid %d, expected %d, reference %s', + $paidAmount, + $expectedSubunits, + $reference + ) + ); + } + + // dispatch the `payment_verify_after` event to update the order status + $this->eventManager->dispatch('paystack_payment_verify_after', [ + "paystack_order" => $order, + ]); + + return [200, "success"]; + } + + $this->logger->warning("Paystack Webhook: settlement check failed", [ + 'reason' => $reason, + 'order_id' => $order->getIncrementId(), + ]); + + if (TransactionValidator::REASON_IN_FLIGHT === $reason) { + // Bank transfer / USSD still settling — the only reason that + // genuinely resolves on its own. Retry unconditionally. + return [503, "pending"]; + } + + // See TransactionValidator::PERMANENT_FOR_WEBHOOK for which reasons + // are safe to report permanent. + $isPermanentReason = $this->transactionValidator->isPermanentForWebhook($reason); + + // Signature-verified path: every real charge fires this webhook, so + // this is the merchant's only visibility into "money moved but the + // order did not advance". Dedupes via historyMarker() so a transient + // reason retried for up to 72h doesn't append a comment every time. + $paidAmount = $this->transactionValidator->paidSubunits($transactionDetails) ?? 'unknown'; + $paidCurrency = $transactionDetails->data->currency ?? 'unknown'; + $this->recordHistory( + $order, + $reference, + $reason, + sprintf( + 'Paystack: payment %s — %s: paid %s %s, expected %s %s, reference %s', + $isPermanentReason ? 'rejected' : 'not applied (retry pending)', + $reason, + $paidAmount, + $paidCurrency, + $expectedSubunits, + $order->getOrderCurrencyCode(), + $reference + ) + ); + + if ($isPermanentReason) { + return [200, "rejected"]; + } + + if (in_array($reason, self::NEVER_RECENCY_BOUNDED, true)) { + // Deliberately excluded from PERMANENT_FOR_WEBHOOK (see + // TransactionValidator's own comments): a swapped key pair or a + // wrong payment method chosen for this order is never fixed by + // the passage of time, so — unlike MALFORMED/unrecognised + // reasons below — these must keep retrying indefinitely rather + // than falling back to permanent once the recency window (900s) + // has elapsed. + return [503, "unverified"]; + } + + // MALFORMED/unrecognised reasons get the same recency bound as + // order-not-found below: keep retrying while the failure could still + // be a not-yet-hydrated relation or an unreadable response, stop + // paying the retry cost once it no longer plausibly is. + if ($this->isRecentTransaction($transactionDetails)) { + return [503, "unverified"]; + } + + return [200, "unverified"]; + } + + /** + * Decides the webhook response when no order matches a verified reference. + * + * @param object $transactionDetails Full envelope PaystackApiClient::verifyTransaction() returns + * @param string $reference + * @return array{0: int, 1: string} + */ + private function resolveOrderNotFound(object $transactionDetails, string $reference): array + { + $this->logger->warning("Paystack Webhook: order not found for reference " . $reference); + + if ($this->isRecentTransaction($transactionDetails)) { + // Recent transaction, no matching order yet — could be a + // genuine order-save race; let Paystack retry within the window. + return [503, "order not found"]; + } + + // Old transaction, still no order: very likely this will never + // have a Magento order at all (see the class constant's comment). + $this->logger->warning("Paystack Webhook: order not found and transaction not recent, treating as permanent", ['reference' => $reference]); + return [200, "order not found"]; + } + + /** + * The stable (reference, reason) marker embedded in a history comment — + * the single definition recordHistory() writes and dedupes against, so + * the human-readable prose around it can be reworded without breaking + * dedupe. + * + * @param string $reference + * @param string $reasonKey + * @return string + */ + private function historyMarker(string $reference, string $reasonKey): string + { + return sprintf('[paystack:%s:%s]', $reference, $reasonKey); + } + + /** + * Writes a merchant-visible order-history comment, but only once per + * (reference, reason) pair: WRONG_METHOD and MALFORMED are transient (503) + * and Paystack's ~72h retry window would otherwise append a near-identical + * comment on every retry of the same rejection. A failed write, and a + * failed lookup of the existing history, must not turn a rejection/ + * overpayment note into a 503 retry storm — log and continue. + * + * @param OrderInterface $order + * @param string $reference + * @param string $reasonKey + * @param string $comment + * @return void + */ + private function recordHistory(OrderInterface $order, string $reference, string $reasonKey, string $comment): void + { + $marker = $this->historyMarker($reference, $reasonKey); + try { + foreach ($order->getStatusHistories() ?? [] as $history) { + $existingComment = $history->getComment() ?? ''; + if (strpos($existingComment, $marker) !== false) { + // Already recorded for this (reference, reason) pair — this is a + // retry of an event we already wrote a comment for. + return; + } + } + + $order->addStatusToHistory($order->getStatus(), $comment . ' ' . $marker); + $this->orderRepository->save($order); + } catch (\Throwable $exc) { + // A failed history write must not turn an already-decided + // rejection into a 503 retry that can never fix it — log and continue. + $this->logger->error("Paystack Webhook: failed to write order history", ['error' => $exc->getMessage()]); + } + } + + /** + * Whether a verify response's transaction is recent enough that "no matching + * order" should still be treated as transient. Reads data.paid_at, falling back + * to data.created_at, then to "treat as recent" if neither is present or parses + * — a missing/unreadable timestamp must not permanently strand a genuine payment. + * + * @param object $transactionDetails Full envelope PaystackApiClient::verifyTransaction() returns + * @return bool + */ + private function isRecentTransaction(object $transactionDetails): bool + { + $data = $transactionDetails->data ?? null; + $timestamp = (is_object($data) ? ($data->paid_at ?? $data->created_at ?? null) : null); + if (!is_string($timestamp) || $timestamp === '') { + return true; + } + + $parsed = strtotime($timestamp); + if ($parsed === false) { + return true; } - $resultFactory->setContents($finalMessage); - return $resultFactory; + return (time() - $parsed) <= self::ORDER_LOOKUP_RETRY_WINDOW_SECONDS; } } diff --git a/Gateway/PaystackApiClient.php b/Gateway/PaystackApiClient.php index 994cbfe..403bf19 100644 --- a/Gateway/PaystackApiClient.php +++ b/Gateway/PaystackApiClient.php @@ -87,6 +87,11 @@ public function verifyTransaction(string $reference): object */ public function validateWebhookSignature(string $rawBody, string $signature): bool { + if ($this->getSecretKey() === '') { + // An unconfigured/empty secret key makes hash_hmac(..., '') computable + // by anyone — never let that trivially satisfy the signature check. + return false; + } $computed = hash_hmac('sha512', $rawBody, $this->getSecretKey()); return hash_equals($computed, $signature); } diff --git a/Model/PaymentManagement.php b/Model/PaymentManagement.php index 2faedc6..29cf6dd 100644 --- a/Model/PaymentManagement.php +++ b/Model/PaymentManagement.php @@ -2,19 +2,19 @@ /** * Paystack Magento2 Module using \Magento\Payment\Model\Method\AbstractMethod * Copyright (C) 2019 Paystack.com - * + * * This file is part of Pstk/Paystack. - * + * * Pstk/Paystack is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ @@ -23,6 +23,7 @@ use Exception; use Pstk\Paystack\Gateway\PaystackApiClient; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Psr\Log\LoggerInterface; class PaymentManagement implements \Pstk\Paystack\Api\PaymentManagementInterface @@ -43,18 +44,25 @@ class PaymentManagement implements \Pstk\Paystack\Api\PaymentManagementInterface */ private $logger; + /** + * @var TransactionValidator + */ + private $transactionValidator; + public function __construct( PaystackApiClient $paystackClient, \Magento\Framework\Event\Manager $eventManager, \Magento\Sales\Api\Data\OrderInterface $orderInterface, \Magento\Checkout\Model\Session $checkoutSession, - LoggerInterface $logger + LoggerInterface $logger, + TransactionValidator $transactionValidator ) { $this->paystackClient = $paystackClient; $this->eventManager = $eventManager; $this->orderInterface = $orderInterface; $this->checkoutSession = $checkoutSession; $this->logger = $logger; + $this->transactionValidator = $transactionValidator; } /** @@ -63,14 +71,28 @@ public function __construct( */ public function verifyPayment($reference) { - + // we are appending quoteid $ref = explode('_-~-_', $reference); + if (count($ref) !== 2 || $ref[0] === '' || $ref[1] === '') { + // Nothing was charged through us yet — the reference the browser sent + // is unusable before any verify call is even made. Distinct from + // REASON_MALFORMED (an unreadable *gateway* answer): this one is + // safe to retry. + $this->logger->warning('Paystack: malformed verify reference'); + return $this->failureResponse(TransactionValidator::REASON_BAD_REFERENCE); + } $reference = $ref[0]; $quoteId = $ref[1]; - + $this->logger->info('Paystack: verifyPayment called', ['reference' => $reference, 'quoteId' => $quoteId]); + // The settlement gate must run before this is set — see Callback.php's + // identical pattern. Once true, anything that throws happened after the + // charge was confirmed real, so the catch block below must not present + // it to the customer as a rejected payment. + $dispatched = false; + try { $transaction_details = $this->paystackClient->verifyTransaction($reference); $this->logger->info('Paystack: transaction verified via API', [ @@ -86,33 +108,92 @@ public function verifyPayment($reference) 'tx_meta_quoteId' => $transaction_details->data->metadata->quoteId ?? 'missing', ]); - if ($order && (string)$order->getQuoteId() === (string)$quoteId && (string)$transaction_details->data->metadata->quoteId === (string)$quoteId) { - - // dispatch the `paystack_payment_verify_after` event to update the order status - $this->eventManager->dispatch('paystack_payment_verify_after', [ - "paystack_order" => $order, + if ($order && (string)$order->getQuoteId() === (string)$quoteId && (string)($transaction_details->data->metadata->quoteId ?? null) === (string)$quoteId) { + + $failureReason = $this->transactionValidator->settlementFailureReason( + $transaction_details, + $order, + $this->paystackClient->isTestMode() + ); + + if ($failureReason === null) { + // dispatch the `paystack_payment_verify_after` event to update the order status + $dispatched = true; + + $this->eventManager->dispatch('paystack_payment_verify_after', [ + "paystack_order" => $order, + ]); + + $this->logger->info('Paystack: verification successful, event dispatched'); + + // Return consistent response format — trimmed to what the JS reads + return json_encode([ + 'status' => true, + 'message' => 'Verification successful', + 'data' => [ + 'status' => $transaction_details->data->status, + 'reference' => $transaction_details->data->reference ?? null, + ], + ]); + } + + $this->logger->warning('Paystack: settlement check failed on inline verify', [ + 'reference' => $reference, + 'quoteId' => $quoteId, + 'reason' => $failureReason, ]); + return $this->failureResponse($failureReason); + } + $this->logger->warning('Paystack: quoteId mismatch — order not updated'); + } catch (\Throwable $e) { + $this->logger->error('Paystack: verifyPayment exception', ['error' => $e->getMessage()]); - $this->logger->info('Paystack: verification successful, event dispatched'); + if ($dispatched) { + // Verification succeeded and the advance was already under way when + // something downstream threw — the observer saves the order before it + // sends the email. Reporting the generic failure response here would + // present a paid order as rejected and invite a second payment. + $this->logger->warning( + 'Paystack: verifyPayment exception after settlement dispatched — reporting success', + ['reference' => $reference] + ); - // Return consistent response format return json_encode([ 'status' => true, - 'message' => 'Verification successful', - 'data' => $transaction_details->data + 'message' => 'Payment received, but we could not finish updating your order. ' + . 'Please do not pay again — contact support if you do not receive a ' + . 'confirmation email shortly.', + 'data' => [ + 'status' => 'success', + 'reference' => $reference, + ], ]); } - $this->logger->warning('Paystack: quoteId mismatch — order not updated'); - } catch (\Throwable $e) { - $this->logger->error('Paystack: verifyPayment exception', ['error' => $e->getMessage()]); - return json_encode([ - 'status' => false, - 'message' => $e->getMessage() - ]); + + return $this->failureResponse('error'); } + return $this->failureResponse('quote_mismatch'); + } + + /** + * Builds the anonymous-caller failure JSON: never echoes amounts, gateway + * messages, or other internal detail — only a machine-readable `reason` and + * a customer-safe `message`. The copy is owned by + * TransactionValidator::customerMessage(), which is also Webhook's and + * Callback's single source for the same classification — this is not a + * fourth, independently maintained policy map. + * + * @param string $reason + * @return string + */ + private function failureResponse(string $reason) + { return json_encode([ 'status' => false, - 'message' => "quoteId doesn't match transaction" + 'reason' => $reason, + // See TransactionValidator::isTerminalForCustomer() for the fail-closed rationale. + 'final' => $this->transactionValidator->isTerminalForCustomer($reason), + 'message' => $this->transactionValidator->customerMessage($reason), ]); } @@ -129,7 +210,7 @@ private function getOrder() } else { return false; } - + if ($lastOrderId) { // load and return the order instance return $this->orderInterface->loadByIncrementId($lastOrderId); diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php index 3fff7b7..a66b5c1 100644 --- a/Test/Unit/Controller/Payment/CallbackTest.php +++ b/Test/Unit/Controller/Payment/CallbackTest.php @@ -95,6 +95,38 @@ private function createController(): Callback ); } + /** + * A settled order: same payment method, currency and grand total the + * matching-currency `settledVerifyData()` amount pays for in full. + */ + private function createSettledOrder(string $incrementId): MockObject + { + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn($incrementId); + + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn(\Pstk\Paystack\Model\Payment\Paystack::CODE); + $order->method('getPayment')->willReturn($payment); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + + return $order; + } + + /** + * Verify-response `data` fields for a successful transaction that settles the + * order `createSettledOrder()` builds (5000.00 NGN => 500000 subunits). + */ + private function settledVerifyData(string $reference, array $overrides = []): array + { + return array_merge([ + 'reference' => $reference, + 'status' => 'success', + 'amount' => 500000, + 'currency' => 'NGN', + ], $overrides); + } + public function testSuccessfulCallbackDispatchesEvent(): void { $controller = $this->createController(); @@ -104,17 +136,13 @@ public function testSuccessfulCallbackDispatchesEvent(): void ->willReturn('000000001_suffix'); $verifyResponse = (object) [ - 'data' => (object) [ - 'reference' => '000000001_suffix', - 'status' => 'success', - ], + 'data' => (object) $this->settledVerifyData('000000001_suffix'), ]; $this->paystackClient->method('verifyTransaction') ->with('000000001_suffix') ->willReturn($verifyResponse); - $order = $this->createMock(\Magento\Sales\Model\Order::class); - $order->method('getIncrementId')->willReturn('000000001'); + $order = $this->createSettledOrder('000000001'); $this->orderInterface->method('loadByIncrementId') ->with('000000001') ->willReturn($order); @@ -139,13 +167,9 @@ public function testOrderIsLoadedFromTheVerifyResponseNotTheRequest(): void $this->paystackClient->method('verifyTransaction') ->with('000000099_attacker') - ->willReturn((object) ['data' => (object) [ - 'reference' => '000000001_suffix', - 'status' => 'success', - ]]); + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001_suffix')]); - $order = $this->createMock(\Magento\Sales\Model\Order::class); - $order->method('getIncrementId')->willReturn('000000001'); + $order = $this->createSettledOrder('000000001'); $this->orderInterface->expects($this->once()) ->method('loadByIncrementId') @@ -360,13 +384,9 @@ public function testThrowableAfterDispatchDoesNotReportFailure(): void $this->request->method('get')->willReturn('000000001'); $this->paystackClient->method('verifyTransaction') - ->willReturn((object) ['data' => (object) [ - 'reference' => '000000001', - 'status' => 'success', - ]]); + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001')]); - $order = $this->createMock(\Magento\Sales\Model\Order::class); - $order->method('getIncrementId')->willReturn('000000001'); + $order = $this->createSettledOrder('000000001'); $this->orderInterface->method('loadByIncrementId')->willReturn($order); $this->eventManager->method('dispatch') @@ -404,4 +424,160 @@ public function testOrderNotFoundRedirectsToFailure(): void $controller->execute(); } + + /** + * D6: a `success` status alone is not settlement — the amount paid must cover the + * order. Short by more than the ±1-subunit tolerance must fail closed exactly like + * the D5 status gate, and must never reach the "payment received" warning branch at + * :127. + */ + public function testAmountBelowToleranceWindowDoesNotDispatchEvent(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('000000001'); + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001', [ + 'amount' => 499998, + ])]); + + $order = $this->createSettledOrder('000000001'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + // AMOUNT_MISMATCH means the payment DID complete, just not for enough — + // asserting the copy is the regression test for the bug where this + // branch used to say "was not completed", which is false here. + $this->messageManager->expects($this->once()) + ->method('addErrorMessage') + ->with($this->callback(function ($m) { + return str_contains((string) $m, 'do not pay again'); + })); + $this->messageManager->expects($this->never())->method('addSuccessMessage'); + $this->messageManager->expects($this->never())->method('addWarningMessage'); + + $controller->execute(); + } + + /** + * A `success` status paid in the wrong currency does not settle an order priced in + * another currency, however close the numbers look. + */ + public function testCurrencyMismatchDoesNotDispatchEvent(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('000000001'); + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001', [ + 'currency' => 'USD', + ])]); + + $order = $this->createSettledOrder('000000001'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->messageManager->expects($this->once()) + ->method('addErrorMessage') + ->with($this->callback(function ($m) { + return str_contains((string) $m, 'do not pay again'); + })); + $this->messageManager->expects($this->never())->method('addSuccessMessage'); + $this->messageManager->expects($this->never())->method('addWarningMessage'); + + $controller->execute(); + } + + /** + * A charge settled against an order that was not placed with Paystack must not + * advance it — narrows the surface a stray/forged reference can act on. + */ + public function testWrongPaymentMethodDoesNotDispatchEvent(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('000000001'); + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001')]); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('000000001'); + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn('checkmo'); + $order->method('getPayment')->willReturn($payment); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->messageManager->expects($this->once()) + ->method('addErrorMessage') + ->with($this->callback(function ($m) { + return str_contains((string) $m, 'do not pay again'); + })); + $this->messageManager->expects($this->never())->method('addSuccessMessage'); + $this->messageManager->expects($this->never())->method('addWarningMessage'); + + $controller->execute(); + } + + /** + * A settled-looking `success` status against a zero/negative expected or paid + * total must not settle the order — the fourth settlement-gate reason, + * completing copy coverage for all of customerMessage()'s default-bucket + * reasons through the callback path. + */ + public function testZeroTotalDoesNotDispatchEvent(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('000000001'); + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001')]); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('000000001'); + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn(\Pstk\Paystack\Model\Payment\Paystack::CODE); + $order->method('getPayment')->willReturn($payment); + $order->method('getGrandTotal')->willReturn(0.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->messageManager->expects($this->once()) + ->method('addErrorMessage') + ->with($this->callback(function ($m) { + return str_contains((string) $m, 'do not pay again'); + })); + $this->messageManager->expects($this->never())->method('addSuccessMessage'); + $this->messageManager->expects($this->never())->method('addWarningMessage'); + + $controller->execute(); + } + + /** + * Overpayment by a single subunit still settles the order — required for + * Paystack's customer-bears-fee configuration, where `data.amount` includes the + * fee on top of the order total. The `paid >= expected` window passes. + */ + public function testOverpayByOneSubunitStillDispatchesEvent(): void + { + $controller = $this->createController(); + + $this->request->method('get')->willReturn('000000001'); + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => (object) $this->settledVerifyData('000000001', [ + 'amount' => 500001, + ])]); + + $order = $this->createSettledOrder('000000001'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->with('paystack_payment_verify_after', ['paystack_order' => $order]); + + $controller->execute(); + } } diff --git a/Test/Unit/Controller/Payment/RecreateTest.php b/Test/Unit/Controller/Payment/RecreateTest.php index ef108f4..3c21b9a 100644 --- a/Test/Unit/Controller/Payment/RecreateTest.php +++ b/Test/Unit/Controller/Payment/RecreateTest.php @@ -28,9 +28,17 @@ class RecreateTest extends TestCase /** @var MockObject|CheckoutSession */ private $checkoutSession; + /** @var MockObject|MessageManager */ + private $messageManager; + + /** @var MockObject|OrderRepositoryInterface */ + private $orderRepository; + private function createController(): Recreate { $this->checkoutSession = $this->createMock(CheckoutSession::class); + $this->messageManager = $this->createMock(MessageManager::class); + $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); $redirect = $this->createMock(Redirect::class); $redirect->method('setUrl')->willReturnSelf(); @@ -44,7 +52,7 @@ private function createController(): Recreate $context->method('getResponse')->willReturn($this->createMock(\Magento\Framework\App\ResponseInterface::class)); $context->method('getObjectManager')->willReturn($this->createMock(\Magento\Framework\ObjectManagerInterface::class)); $context->method('getEventManager')->willReturn($this->createMock(EventManager::class)); - $context->method('getMessageManager')->willReturn($this->createMock(MessageManager::class)); + $context->method('getMessageManager')->willReturn($this->messageManager); $context->method('getRedirect')->willReturn($this->createMock(RedirectInterface::class)); $context->method('getActionFlag')->willReturn($this->createMock(\Magento\Framework\App\ActionFlag::class)); $context->method('getView')->willReturn($this->createMock(\Magento\Framework\App\ViewInterface::class)); @@ -57,11 +65,11 @@ private function createController(): Recreate return new Recreate( $context, $this->createMock(PageFactory::class), - $this->createMock(OrderRepositoryInterface::class), + $this->orderRepository, $this->createMock(Order::class), $this->checkoutSession, $this->createMock(PaymentHelper::class), - $this->createMock(MessageManager::class), + $this->messageManager, $this->createMock(ConfigProvider::class), $this->createMock(StoreManagerInterface::class), $this->createMock(EventManager::class), @@ -72,19 +80,59 @@ private function createController(): Recreate ); } + /** + * A payment placed with this module's method, so a test can vary the order + * state without the payment-method guard rejecting it for its own reasons. + */ + private function paystackPayment() + { + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn(\Pstk\Paystack\Model\Payment\Paystack::CODE); + + return $payment; + } + public function testCancelsActiveOrderAndRestoresQuote(): void { $controller = $this->createController(); + $payment = $this->paystackPayment(); + $order = $this->createMock(Order::class); $order->method('getId')->willReturn(1); $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getPayment')->willReturn($payment); + + $order->expects($this->once()) + ->method('registerCancellation') + ->with('Payment failed or cancelled') + ->willReturn($order); + $order->expects($this->never())->method('save'); + $this->orderRepository->expects($this->once())->method('save')->with($order); + + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->checkoutSession->expects($this->once())->method('restoreQuote'); + + $controller->execute(); + } + + public function testPendingPaymentOrderIsCancelledAndQuoteRestored(): void + { + $controller = $this->createController(); + + $payment = $this->paystackPayment(); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT); + $order->method('getPayment')->willReturn($payment); $order->expects($this->once()) ->method('registerCancellation') ->with('Payment failed or cancelled') ->willReturn($order); - $order->expects($this->once())->method('save'); + $order->expects($this->never())->method('save'); + $this->orderRepository->expects($this->once())->method('save')->with($order); $this->checkoutSession->method('getLastRealOrder')->willReturn($order); $this->checkoutSession->expects($this->once())->method('restoreQuote'); @@ -92,8 +140,70 @@ public function testCancelsActiveOrderAndRestoresQuote(): void $controller->execute(); } - public function testAlreadyCancelledOrderIsNotCancelledAgain(): void + public function testNonPaystackOrderIsNotCancelledAndQuoteIsNotRestored(): void { + // Even a pre-payment (new/pending_payment) order must not be cancelled and + // its quote restored by an anonymous GET if it wasn't placed with + // Paystack — otherwise a third-party page can CSRF a shopper's pending + // offline order (Check/Money Order, bank transfer, COD) into "canceled". + $controller = $this->createController(); + + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn('checkmo'); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getPayment')->willReturn($payment); + + $order->expects($this->never())->method('registerCancellation'); + $order->expects($this->never())->method('save'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->checkoutSession->expects($this->never())->method('restoreQuote'); + + $this->messageManager->expects($this->once())->method('addErrorMessage'); + + $controller->execute(); + } + + public function testProcessingOrderIsNotCancelledAndQuoteIsNotRestored(): void + { + $controller = $this->createController(); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $order->method('getState')->willReturn(Order::STATE_PROCESSING); + // Paystack's own method, deliberately: without it the payment-method + // guard would reject this order too, and the test would pass whether or + // not the state guard exists. The state is the only thing wrong here. + $order->method('getPayment')->willReturn($this->paystackPayment()); + + $order->expects($this->never())->method('registerCancellation'); + $order->expects($this->never())->method('save'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->checkoutSession->expects($this->never())->method('restoreQuote'); + + $this->messageManager->expects($this->once())->method('addErrorMessage'); + + $result = $controller->execute(); + + $this->assertNotNull($result); + } + + public function testAlreadyCancelledOrderIsNotRestorable(): void + { + // A paid/processing order is not the only state outside the {new, + // pending_payment} allow-list: a previously-cancelled order also falls + // outside it. This is a behavior change from before the guard existed — + // a second hit on an already-cancelled order used to still restore the + // quote (silent double-restore); it now shows the "could not restart" + // notice instead. Cancellation is never a security risk (no money moved), + // but the guard is an allow-list, not a deny-list, on purpose: it must + // fail closed on any state it doesn't explicitly know is pre-payment. $controller = $this->createController(); $order = $this->createMock(Order::class); @@ -101,24 +211,34 @@ public function testAlreadyCancelledOrderIsNotCancelledAgain(): void $order->method('getState')->willReturn(Order::STATE_CANCELED); $order->expects($this->never())->method('registerCancellation'); + $this->orderRepository->expects($this->never())->method('save'); $this->checkoutSession->method('getLastRealOrder')->willReturn($order); - $this->checkoutSession->expects($this->once())->method('restoreQuote'); + $this->checkoutSession->expects($this->never())->method('restoreQuote'); + + $this->messageManager->expects($this->once())->method('addErrorMessage'); $controller->execute(); } - public function testNoOrderStillRestoresQuote(): void + public function testNoOrderIdRedirectsToCheckoutWithoutErrorOrCancel(): void { + // No last real order at all is a benign repeat hit (double-submit, back + // button, refresh, expired session) — not an incident, so it should + // silently restart checkout rather than land on the failure page. $controller = $this->createController(); $order = $this->createMock(Order::class); $order->method('getId')->willReturn(null); $order->expects($this->never())->method('registerCancellation'); + $order->expects($this->never())->method('getState'); + $this->orderRepository->expects($this->never())->method('save'); $this->checkoutSession->method('getLastRealOrder')->willReturn($order); - $this->checkoutSession->expects($this->once())->method('restoreQuote'); + $this->checkoutSession->expects($this->never())->method('restoreQuote'); + + $this->messageManager->expects($this->never())->method('addErrorMessage'); $controller->execute(); } diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php index 45e66b9..7d13de2 100644 --- a/Test/Unit/Controller/Payment/WebhookTest.php +++ b/Test/Unit/Controller/Payment/WebhookTest.php @@ -8,6 +8,7 @@ use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Exception\ApiException; use Pstk\Paystack\Gateway\Validator\TransactionValidator; +use Pstk\Paystack\Model\Payment\Paystack; use Pstk\Paystack\Model\Ui\ConfigProvider; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; @@ -64,6 +65,7 @@ protected function setUp(): void $this->rawResult = $this->createMock(Raw::class); $this->rawResult->method('setContents')->willReturnSelf(); + $this->rawResult->method('setHttpResponseCode')->willReturnSelf(); $resultFactory = $this->createMock(ResultFactory::class); $resultFactory->method('create') @@ -110,6 +112,50 @@ protected function setUp(): void ); } + protected function tearDown(): void + { + // The webhook's quoteId fallback reaches for ObjectManager::getInstance() + // directly. A test that primes the static instance to exercise that branch + // must not leak it into whichever test runs next in this process. + $reflection = new \ReflectionClass(\Magento\Framework\App\ObjectManager::class); + $property = $reflection->getProperty('_instance'); + $property->setValue(null, null); + } + + /** + * A settled order: same payment method, currency and grand total the + * matching-currency `settledVerifyData()` amount pays for in full. + */ + private function createSettledOrder(string $incrementId): MockObject + { + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getId')->willReturn(1); + $order->method('getIncrementId')->willReturn($incrementId); + $order->method('getStatus')->willReturn('pending'); + + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + $order->method('getPayment')->willReturn($payment); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + + return $order; + } + + /** + * Verify-response `data` fields for a successful transaction that settles the + * order `createSettledOrder()` builds (5000.00 NGN => 500000 subunits). + */ + private function settledVerifyData(string $reference, array $overrides = []): array + { + return array_merge([ + 'reference' => $reference, + 'status' => 'success', + 'amount' => 500000, + 'currency' => 'NGN', + ], $overrides); + } + public function testInvalidSignatureReturnsAuthFailed(): void { $this->request->method('getContent')->willReturn('{"event":"charge.success"}'); @@ -122,6 +168,9 @@ public function testInvalidSignatureReturnsAuthFailed(): void ->with($this->callback(function ($val) { return $val === 'auth failed'; })); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(401); $this->eventManager->expects($this->never())->method('dispatch'); @@ -138,6 +187,9 @@ public function testMissingSignatureReturnsAuthFailed(): void ->with($this->callback(function ($val) { return $val === 'auth failed'; })); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(401); $this->controller->execute(); } @@ -158,10 +210,7 @@ public function testChargeSuccessWithValidOrder(): void $this->paystackClient->method('validateWebhookSignature')->willReturn(true); $verifyResponse = (object) [ - 'data' => (object) [ - 'reference' => 'ORDER_001', - 'status' => 'success', - ], + 'data' => (object) $this->settledVerifyData('ORDER_001'), ]; $this->paystackClient->method('verifyTransaction') ->with('ORDER_001') @@ -172,15 +221,15 @@ public function testChargeSuccessWithValidOrder(): void ->method('logTransactionSuccess') ->with('ORDER_001', 'pk_test_123'); - $order = $this->createMock(\Magento\Sales\Model\Order::class); - $order->method('getId')->willReturn(1); - $order->method('getIncrementId')->willReturn('ORDER_001'); - $order->method('getStatus')->willReturn('pending'); + $order = $this->createSettledOrder('ORDER_001'); + $order->expects($this->never())->method('addStatusToHistory'); $this->orderInterface->method('loadByIncrementId') ->with('ORDER_001') ->willReturn($order); + $this->orderRepository->expects($this->never())->method('save'); + $this->eventManager->expects($this->once()) ->method('dispatch') ->with('paystack_payment_verify_after', ['paystack_order' => $order]); @@ -188,6 +237,9 @@ public function testChargeSuccessWithValidOrder(): void $this->rawResult->expects($this->atLeastOnce()) ->method('setContents') ->with('success'); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); $this->controller->execute(); } @@ -222,9 +274,81 @@ public function testChargeSuccessWithQuoteIdFallback(): void $emptyOrder->method('getId')->willReturn(null); $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); - // The webhook uses ObjectManager::getInstance() for the fallback path, - // which cannot be easily unit-tested. Verify the loadByIncrementId was attempted. + // The webhook uses ObjectManager::getInstance() for the fallback path, which + // cannot be easily unit-tested here without priming the static instance (done + // separately below in testQuoteIdFallbackWithAmountMismatchDoesNotDispatch, the + // test that actually exercises this branch's settlement gate). This test is + // limited to the behavioral floor that holds regardless of how that call + // resolves: the lookup was attempted, and nothing dispatched or saved off it. $this->orderInterface->expects($this->once())->method('loadByIncrementId'); + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->controller->execute(); + } + + /** + * The settlement gate applies on the quoteId-fallback lookup too, not only the + * by-reference lookup — a mismatch found via the fallback must not dispatch. + */ + public function testQuoteIdFallbackWithAmountMismatchDoesNotDispatch(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'PSK_ref456', + 'metadata' => ['quoteId' => '77'], + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('PSK_ref456', ['amount' => 499998]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $order = $this->createSettledOrder('000000055'); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('amount_mismatch')); + + // OrderSearchResultInterface itself has no getFirstItem() — the concrete + // collection orderRepository->getList() actually returns does, which is what + // Webhook.php's fallback branch relies on. + $searchResult = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Collection::class); + $searchResult->method('getTotalCount')->willReturn(1); + $searchResult->method('getFirstItem')->willReturn($order); + $this->orderRepository->method('getList')->willReturn($searchResult); + $this->orderRepository->expects($this->once())->method('save')->with($order); + + $searchCriteriaBuilder = $this->createMock(\Magento\Framework\Api\SearchCriteriaBuilder::class); + $searchCriteriaBuilder->method('addFilter')->willReturnSelf(); + $searchCriteriaBuilder->method('create') + ->willReturn($this->createMock(\Magento\Framework\Api\SearchCriteria::class)); + + $objectManager = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); + $objectManager->method('create') + ->with('Magento\Framework\Api\SearchCriteriaBuilder') + ->willReturn($searchCriteriaBuilder); + \Magento\Framework\App\ObjectManager::setInstance($objectManager); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('rejected'); $this->controller->execute(); } @@ -240,10 +364,20 @@ public function testInvalidJsonPayloadReturnsInvalidPayload(): void ->with($this->callback(function ($val) { return $val === 'invalid payload'; })); + // Malformed JSON cannot be fixed by a retry — permanent, not transient. + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); $this->controller->execute(); } + /** + * Event types this switch does not act on are permanently accepted (200, + * "ignored"), never retried — pins the explicit fall-through that replaced + * the dead `$finalMessage` variable (it could only ever hold its initializer, + * "failed", and nothing ever read it as anything else). + */ public function testNonChargeSuccessEventIsIgnored(): void { $rawBody = json_encode([ @@ -258,9 +392,20 @@ public function testNonChargeSuccessEventIsIgnored(): void $this->eventManager->expects($this->never())->method('dispatch'); $this->paystackClient->expects($this->never())->method('verifyTransaction'); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('ignored'); + $this->controller->execute(); } + /** + * A `charge.success` whose payload status is not itself "success" falls + * through to the same permanent-accept branch as an unhandled event type. + */ public function testChargeSuccessWithNonSuccessStatusIsIgnored(): void { $rawBody = json_encode([ @@ -278,32 +423,1043 @@ public function testChargeSuccessWithNonSuccessStatusIsIgnored(): void $this->paystackClient->expects($this->never())->method('verifyTransaction'); $this->eventManager->expects($this->never())->method('dispatch'); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('ignored'); + $this->controller->execute(); } - public function testApiExceptionDuringVerifyReturnsErrorMessage(): void + /** + * The webhook payload's own status only gates whether re-verification is + * attempted; the re-verified status must also be checked (this closed a gap + * where only `event.data.status` was read, never the verify response's status). + * + * The fixture deliberately includes `amount`/`currency` so the validator reaches + * `REASON_IN_FLIGHT` (a fixture missing them would resolve to `REASON_MALFORMED` + * first, since that check runs earlier — same 503, but the wrong reason, and this + * test's "no history written" assertion only holds for IN_FLIGHT under the + * deny-list history behavior). + */ + public function testReVerifyPendingStatusReturns503WithoutDispatchOrHistory(): void { $rawBody = json_encode([ 'event' => 'charge.success', 'data' => [ 'status' => 'success', - 'reference' => 'ORDER_003', + 'reference' => 'ORDER_PENDING', ], ]); $this->request->method('getContent')->willReturn($rawBody); $this->request->method('getHeader')->willReturn('valid_sig'); $this->paystackClient->method('validateWebhookSignature')->willReturn(true); - $this->paystackClient->method('verifyTransaction') - ->willThrowException(new ApiException('API down')); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_PENDING', ['status' => 'pending']), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getId')->willReturn(1); + $order->method('getIncrementId')->willReturn('ORDER_PENDING'); + $order->expects($this->never())->method('addStatusToHistory'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); $this->rawResult->expects($this->atLeastOnce()) ->method('setContents') - ->with($this->callback(function ($val) { - return $val === 'API down'; - })); + ->with('pending'); + + $this->controller->execute(); + } + + /** + * `REASON_MALFORMED` means we could not read the gateway's answer (verify + * returned a body with the reference present but the amount unreadable) — the + * textbook transient case. A permanent 200 here would strand a real payment with + * no retry and no merchant trace, so this must be 503, not 200. The deny-list + * history write still fires (MALFORMED is not IN_FLIGHT), so the merchant gets + * visibility even while Paystack is retrying. + */ + public function testMalformedVerifyResponseReturns503WithHistory(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_060', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_060', ['amount' => null]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createSettledOrder('ORDER_060'); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('malformed')); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->orderRepository->expects($this->once())->method('save')->with($order); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('unverified'); + + $this->controller->execute(); + } + + /** + * `REASON_WRONG_METHOD` is derived from `$order->getPayment()`, a lazily-loaded + * relation, not a row invariant — a permanent rejection here could burn a genuine + * payment if it is ever observed unhydrated. Transient (503), with the deny-list + * history write still firing so the merchant is not left without a trace. + */ + public function testWrongPaymentMethodReturns503WithHistory(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_050', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_050'), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getId')->willReturn(1); + $order->method('getIncrementId')->willReturn('ORDER_050'); + $order->method('getStatus')->willReturn('pending'); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $wrongPayment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $wrongPayment->method('getMethod')->willReturn('checkmo'); + $order->method('getPayment')->willReturn($wrongPayment); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('wrong_method')); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->orderRepository->expects($this->once())->method('save')->with($order); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('unverified'); + + $this->controller->execute(); + } + + /** + * A signed `charge.success` with no `data.reference` cannot be re-verified at + * all — `verifyTransaction()` must never be called with it, and the `?? null` + * guard must reject it before any PHP error can escape. + */ + public function testChargeSuccessWithNoEventReferenceReturnsInvalidPayload(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $this->paystackClient->expects($this->never())->method('verifyTransaction'); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('invalid payload'); + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + return true; + }, E_WARNING | E_NOTICE | E_DEPRECATED); + + try { + $this->controller->execute(); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings, 'Missing data.reference must not emit PHP warnings/notices.'); + } + + /** + * The verify response itself can come back with no readable `data.reference` + * (the gateway's answer, not our payload) — `logTransactionSuccess()` takes a + * non-nullable string, so it must never be called with the null it would + * otherwise receive. Transient (503): a retry may get a readable response. + */ + public function testVerifyResponseWithNoReferenceReturns503WithoutCallingLogSuccess(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_070', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) [ + 'status' => 'success', + 'amount' => 500000, + 'currency' => 'NGN', + ], + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + + $this->paystackClient->expects($this->never())->method('logTransactionSuccess'); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('unverified'); + + $this->controller->execute(); + } + + /** + * Order-not-found is bounded by transaction recency, not treated as transient + * forever: a recent `paid_at` still gets the retry window. + */ + public function testOrderNotFoundWithRecentPaidAtReturns503(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_080', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_080', ['paid_at' => date('c')]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('order not found'); + + $this->controller->execute(); + } + + /** + * A `paid_at` outside the retry window means order-not-found is treated as + * permanent (200) — this is what protects the webhook endpoint from Paystack + * backing it off/disabling it over events that will never have a Magento order + * (Payment Pages, dashboard charges, subscription renewals, another integration + * on the same account). + */ + public function testOrderNotFoundWithStalePaidAtReturns200(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_090', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_090', ['paid_at' => date('c', time() - 7200)]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('order not found'); + + $this->controller->execute(); + } + + /** + * isRecentTransaction() reads `data.paid_at`, falling back to `data.created_at` + * when `paid_at` is absent — this pins that fallback specifically, not just the + * `paid_at`-present cases above. + */ + public function testOrderNotFoundFallsBackToCreatedAtWhenPaidAtMissing(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_091', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + // No paid_at at all; created_at is recent, so the fallback must still + // yield a transient (503), not a permanent (200), classification. + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_091', ['created_at' => date('c')]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('order not found'); + + $this->controller->execute(); + } + + /** + * A stale `created_at` (no `paid_at` at all) must be treated as permanent + * (200), exactly like a stale `paid_at` — the fallback is not just read, its + * value is actually compared against the retry window. + */ + public function testOrderNotFoundWithStaleCreatedAtAndNoPaidAtReturns200(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_092', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_092', ['created_at' => date('c', time() - 7200)]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('order not found'); + + $this->controller->execute(); + } + + /** + * A `paid_at` value strtotime() cannot parse must not permanently strand a + * genuine payment — isRecentTransaction() treats an unparseable timestamp as + * recent (503, retry), the same fail-open behavior as a missing timestamp. + */ + public function testOrderNotFoundWithUnparseablePaidAtReturns503(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_093', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_093', ['paid_at' => 'not-a-real-timestamp']), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('order not found'); + + $this->controller->execute(); + } + + public function testApiExceptionDuringVerifyReturns503WithoutLeakingMessage(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_003', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + $this->paystackClient->method('verifyTransaction') + ->willThrowException(new ApiException('API down')); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with($this->callback(function ($val) { + return is_string($val) && strpos($val, 'API down') === false; + })); + + $this->controller->execute(); + } + + /** + * ApiException means Paystack itself answered with a permanent gateway + * rejection (e.g. "Transaction reference not found"), not a genuine + * transient network failure — indistinguishable from one without a + * recency signal. No verify response exists at this catch site, so the + * webhook payload's own paid_at is reused: recent still retries (503, + * covered above by testApiExceptionDuringVerifyReturns503WithoutLeakingMessage, + * whose payload has no paid_at and so fails open to "recent"); stale + * gives up as permanent (200) rather than retrying for Paystack's full + * ~72h budget. + * + * @dataProvider staleApiExceptionProvider + */ + public function testApiExceptionRecencyBoundToPermanent(string $paidAt, int $expectedCode): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_005', + 'paid_at' => $paidAt, + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + $this->paystackClient->method('verifyTransaction') + ->willThrowException(new ApiException('Transaction reference not found')); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with($expectedCode); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('unverified'); + + $this->controller->execute(); + } + + public static function staleApiExceptionProvider(): array + { + return [ + 'recent' => [date('c', time() - 60), 503], + 'stale' => [date('c', time() - 7200), 200], + ]; + } + + /** + * The narrower `catch (ApiException $e)` block above is recency-bound + * (see testApiExceptionRecencyBoundToPermanent) — but a non-ApiException + * \Throwable (a TypeError from a bad param, a RuntimeException from + * anything else in the try block) must fall through to the outer + * `catch (\Throwable $exc)` and get an unconditional 503, never the + * ApiException branch's stale-becomes-200 treatment. Deliberately uses a + * stale paid_at — if this exception were ever accidentally caught by the + * ApiException branch (or a broadened catch swallowed it there), this + * would regress to 200 and this test would catch it. + */ + public function testGenericThrowableDuringVerifyReturns503Unconditionally(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_006', + 'paid_at' => date('c', time() - 7200), + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + $this->paystackClient->method('verifyTransaction') + ->willThrowException(new \TypeError('Argument #1 must be of type string, null given')); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + // "error" (the outer \Throwable branch's literal) pins the branch + // directly, rather than only ruling out the message leaking through — + // the ApiException branch's body is "unverified", never "error". + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('error'); + + $this->controller->execute(); + } + + /** + * D6 on the webhook path: a `success` status alone is not settlement — short by + * more than the ±1-subunit tolerance must reject and record the merchant-visible + * history comment. + */ + public function testAmountBelowToleranceWindowReturnsRejectedWithHistory(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_010', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_010', ['amount' => 499998]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createSettledOrder('ORDER_010'); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('amount_mismatch')); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->orderRepository->expects($this->once())->method('save')->with($order); + $this->eventManager->expects($this->never())->method('dispatch'); + // logTransactionSuccess() only fires once the settlement gate passes — + // a rejected settlement must not tell Paystack's plugin tracker that a + // charge succeeded. + $this->paystackClient->expects($this->never())->method('logTransactionSuccess'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('rejected'); + + $this->controller->execute(); + } + + /** + * A `success` status paid in the wrong currency does not settle an order priced + * in another currency, however close the numbers look. + */ + public function testCurrencyMismatchReturnsRejectedWithHistory(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_020', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_020', ['currency' => 'USD']), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createSettledOrder('ORDER_020'); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('currency_mismatch')); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->orderRepository->expects($this->once())->method('save')->with($order); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('rejected'); + + $this->controller->execute(); + } + + /** + * Overpayment by a single subunit still settles the order — required for + * Paystack's customer-bears-fee configuration — but the surplus is recorded so + * the merchant has visibility into it. + */ + public function testOverpayByOneSubunitDispatchesAndRecordsHistory(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_030', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_030', ['amount' => 500001]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createSettledOrder('ORDER_030'); + $order->expects($this->once()) + ->method('addStatusToHistory') + ->with('pending', $this->stringContains('overpaid')); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->orderRepository->expects($this->once())->method('save')->with($order); + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->with('paystack_payment_verify_after', ['paystack_order' => $order]); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('success'); + + $this->controller->execute(); + } + + public function testOrderNotFoundByEitherLookupReturns503(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_404', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_404'), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + // No metadata.quoteId, so the fallback lookup is never attempted. + $emptyOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $emptyOrder->method('getId')->willReturn(null); + $this->orderInterface->method('loadByIncrementId')->willReturn($emptyOrder); + + $this->eventManager->expects($this->never())->method('dispatch'); + $this->orderRepository->expects($this->never())->method('save'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + // Without this, a code path that throws (and is swallowed by the outer + // \Throwable catch, which also sets 503) would satisfy the response-code + // assertion above for the wrong reason — this is the only thing that + // actually discriminates "order not found, recent" from "an exception + // occurred and got caught". + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('order not found'); + + $this->controller->execute(); + } + + /** + * A signed-but-malformed payload (`data` present, `status` missing) must take a + * clean rejection branch — never call verify or dispatch, and never emit a PHP + * warning while doing it. + */ + /** + * @dataProvider malformedSignedPayloadProvider + */ + public function testSignedMalformedEventDataIsCleanlyRejected(array $data): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => $data, + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $this->paystackClient->expects($this->never())->method('verifyTransaction'); + $this->eventManager->expects($this->never())->method('dispatch'); + + // The `?? null` guards exist precisely so a malformed payload never touches an + // undefined property. Fail the test on the PHP warning that regression would + // reintroduce, rather than trusting the mock expectations alone to catch it. + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + return true; + }, E_WARNING | E_NOTICE | E_DEPRECATED); + + try { + $this->controller->execute(); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings, 'Malformed webhook payload must not emit PHP warnings/notices.'); + } + + public static function malformedSignedPayloadProvider(): array + { + return [ + 'status missing' => [['reference' => 'ORDER_012']], + ]; + } + + /** + * `data` absent from the event entirely, or explicitly `null`, are the shapes that + * most stress the `$event->data->status ?? null` chain — no intermediate object + * exists at all, not just a missing property on one. + * + * @dataProvider missingDataPropertyProvider + */ + public function testSignedPayloadWithNoDataPropertyIsCleanlyRejected(array $eventPayload): void + { + $rawBody = json_encode($eventPayload); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $this->paystackClient->expects($this->never())->method('verifyTransaction'); + $this->eventManager->expects($this->never())->method('dispatch'); + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + return true; + }, E_WARNING | E_NOTICE | E_DEPRECATED); + + try { + $this->controller->execute(); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings, 'A webhook payload with no usable `data` property must not emit PHP warnings/notices.'); + } + + public static function missingDataPropertyProvider(): array + { + return [ + 'data key absent entirely' => [['event' => 'charge.success']], + 'data explicitly null' => [['event' => 'charge.success', 'data' => null]], + ]; + } + + /** + * The signature-verified path writes a merchant-visible history comment on + * rejection. If that write itself throws — including a bare `\Error`, not just + * `\Exception` — it must not turn a clean permanent rejection into a 503: Paystack + * would retry a rejection retrying can never fix. + * + * @dataProvider historyWriteFailureProvider + */ + public function testHistoryWriteFailureDoesNotTurnRejectionIntoRetry(\Throwable $thrown): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_040', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_040', ['amount' => 499998]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createSettledOrder('ORDER_040'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + $this->orderRepository->method('save')->willThrowException($thrown); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('rejected'); + + $this->controller->execute(); + } + + public static function historyWriteFailureProvider(): array + { + return [ + 'RuntimeException from save()' => [new \RuntimeException('deadlock')], + 'TypeError-shaped failure' => [new \TypeError('unexpected type')], + ]; + } + + /** + * WRONG_METHOD and MALFORMED are transient (503) and on the history + * deny-list, so Paystack's ~72h retry window redelivers the exact same + * event repeatedly. recordHistory() must not append a duplicate comment on + * a second delivery it has already recorded a comment for — this is the + * regression test for that amplification. + */ + /** + * MALFORMED is transient so a momentarily unreadable response still gets + * retried — but only while that is still a plausible explanation. Past + * the window the answer is not going to change, and 72h of retries each + * re-running an outbound verify call buys nothing, so it falls back to + * permanent (200) once stale. + * + * WRONG_METHOD and MODE_MISMATCH are different: the order really was + * placed with a different method, or a key pair really is swapped/ + * misconfigured — neither is fixed by the passage of time, so both stay + * on NEVER_RECENCY_BOUNDED and keep retrying (503) indefinitely, + * regardless of transaction age. Data provider covers all three reasons + * on both sides of the bound. + * + * @dataProvider staleReasonProvider + */ + public function testTransientReasonBecomesPermanentOnceTheTransactionIsStale( + array $verifyOverrides, + string $paymentMethod, + int $expectedCode + ): void { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => ['status' => 'success', 'reference' => 'ORDER_046'], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_046', $verifyOverrides), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getId')->willReturn(1); + $order->method('getIncrementId')->willReturn('ORDER_046'); + $order->method('getStatus')->willReturn('pending'); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn($paymentMethod); + $order->method('getPayment')->willReturn($payment); + $order->method('getStatusHistories')->willReturn([]); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with($expectedCode); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('unverified'); + + $this->controller->execute(); + } + + public static function staleReasonProvider(): array + { + $recent = date('c', time() - 60); + $stale = date('c', time() - 7200); + + return [ + // Wrong payment method: the order really was placed with something + // else, so this never resolves — retries indefinitely regardless + // of age (NEVER_RECENCY_BOUNDED). + 'wrong_method, recent' => [['paid_at' => $recent], 'checkmo', 503], + 'wrong_method, stale' => [['paid_at' => $stale], 'checkmo', 503], + // Swapped/misconfigured key pair: also never resolves on its own — + // retries indefinitely regardless of age (NEVER_RECENCY_BOUNDED). + 'mode_mismatch, recent' => [['paid_at' => $recent, 'domain' => 'test'], Paystack::CODE, 503], + 'mode_mismatch, stale' => [['paid_at' => $stale, 'domain' => 'test'], Paystack::CODE, 503], + // Unreadable verify response: amount absent entirely. Falls back + // to permanent once stale — the answer may still change while + // recent (not-yet-hydrated relation), but not indefinitely. + 'malformed, recent' => [['paid_at' => $recent, 'amount' => null], Paystack::CODE, 503], + 'malformed, stale' => [['paid_at' => $stale, 'amount' => null], Paystack::CODE, 200], + ]; + } + + public function testDuplicateWebhookDeliveryWritesHistoryCommentOnlyOnce(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_045', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_045', ['amount' => 499998]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + $order = $this->createSettledOrder('ORDER_045'); + + // A prior delivery of this exact event already wrote the rejection comment + // for this (reference, reason) pair. The prose here is deliberately NOT the + // wording the controller produces today: deduplication must key off the + // machine-readable marker, so that rewording the human-readable half — or + // humanising `amount_mismatch` — cannot silently start appending a fresh + // comment on every one of Paystack's retries. + $existingHistory = $this->createMock(\Magento\Sales\Api\Data\OrderStatusHistoryInterface::class); + $existingHistory->method('getComment')->willReturn( + 'Totally different prose about an amount mismatch. [paystack:ORDER_045:amount_mismatch]' + ); + $order->method('getStatusHistories')->willReturn([$existingHistory]); + + $order->expects($this->never())->method('addStatusToHistory'); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->orderRepository->expects($this->never())->method('save'); + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(200); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('rejected'); $this->controller->execute(); } diff --git a/Test/Unit/Gateway/PaystackApiClientTest.php b/Test/Unit/Gateway/PaystackApiClientTest.php index 4408463..a938050 100644 --- a/Test/Unit/Gateway/PaystackApiClientTest.php +++ b/Test/Unit/Gateway/PaystackApiClientTest.php @@ -111,6 +111,50 @@ public function testIsTestModeFalseWhenConfigTestModeIsOff(): void $this->assertFalse($this->client->isTestMode()); } + /** + * An unconfigured/empty secret key makes hash_hmac(..., '') computable by + * anyone, so it must never be allowed to trivially satisfy the signature + * check — even a signature genuinely computed against the empty key must + * be rejected. + */ + public function testValidateWebhookSignatureEmptySecretKeyFails(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return true; + if ($field === 'test_secret_key') return ''; + return null; + }); + + $rawBody = '{"event":"charge.success"}'; + $signatureComputedAgainstEmptyKey = hash_hmac('sha512', $rawBody, ''); + + $this->assertFalse($this->client->validateWebhookSignature($rawBody, $signatureComputedAgainstEmptyKey)); + } + + /** + * The real unconfigured-config shape: `getConfigData()` returns `null` for + * an unset field (the `?? null` default this test's callback simulates via + * falling through to `return null`), not an already-empty string — + * `getSecretKey()`'s `(string)` cast turns that into `''`, and this + * confirms the cast-then-guard chain rejects it end to end, not just the + * already-empty-string shortcut above. + */ + public function testValidateWebhookSignatureNullSecretKeyFails(): void + { + $this->paymentMethod->method('getConfigData') + ->willReturnCallback(function ($field) { + if ($field === 'test_mode') return true; + if ($field === 'test_secret_key') return null; + return null; + }); + + $rawBody = '{"event":"charge.success"}'; + $signatureComputedAgainstEmptyKey = hash_hmac('sha512', $rawBody, ''); + + $this->assertFalse($this->client->validateWebhookSignature($rawBody, $signatureComputedAgainstEmptyKey)); + } + public function testValidateWebhookSignatureTampered(): void { $this->paymentMethod->method('getConfigData') diff --git a/Test/Unit/Model/PaymentManagementTest.php b/Test/Unit/Model/PaymentManagementTest.php index c8a77cb..6c0bdea 100644 --- a/Test/Unit/Model/PaymentManagementTest.php +++ b/Test/Unit/Model/PaymentManagementTest.php @@ -7,8 +7,11 @@ use Pstk\Paystack\Model\PaymentManagement; use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Exception\ApiException; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; +use Pstk\Paystack\Model\Payment\Paystack; use Magento\Framework\Event\Manager as EventManager; use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Model\Order\Payment as OrderPayment; use Magento\Checkout\Model\Session as CheckoutSession; use Psr\Log\LoggerInterface; @@ -45,20 +48,73 @@ protected function setUp(): void $this->eventManager, $this->orderInterface, $this->checkoutSession, - $this->logger + $this->logger, + new TransactionValidator($this->createMock(LoggerInterface::class)) ); } + /** + * Wires the checkout session / order repository mocks so verifyPayment() + * finds a last-real-order that settlement-matches the given quoteId, with + * payment method/grand total/currency configured for the validator. + * + * @param string $quoteId + * @param float $grandTotal + * @param string $currencyCode + * @return MockObject|\Magento\Sales\Model\Order + */ + private function stubMatchingOrder(string $quoteId, float $grandTotal = 5000.00, string $currencyCode = 'NGN') + { + $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $lastOrder->method('getIncrementId')->willReturn('000000001'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); + + $payment = $this->createMock(OrderPayment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getQuoteId')->willReturn($quoteId); + $order->method('getPayment')->willReturn($payment); + $order->method('getGrandTotal')->willReturn($grandTotal); + $order->method('getOrderCurrencyCode')->willReturn($currencyCode); + $order->method('getIncrementId')->willReturn('000000001'); + + $this->orderInterface->method('loadByIncrementId') + ->with('000000001') + ->willReturn($order); + + return $order; + } + + /** + * @param string $status + * @param string $quoteId + * @param int $amount + * @param string $currency + * @return object + */ + private function buildTxData( + string $status, + string $quoteId, + int $amount = 500000, + string $currency = 'NGN', + string $reference = 'PSK_abc123' + ): object { + return (object) [ + 'status' => $status, + 'reference' => $reference, + 'amount' => $amount, + 'currency' => $currency, + 'metadata' => (object) ['quoteId' => $quoteId], + ]; + } + public function testVerifyPaymentSuccessful(): void { $quoteId = '42'; $reference = 'PSK_abc123_-~-_' . $quoteId; - $txData = (object) [ - 'status' => 'success', - 'reference' => 'PSK_abc123', - 'metadata' => (object) ['quoteId' => $quoteId], - ]; + $txData = $this->buildTxData('success', $quoteId); $apiResponse = (object) ['data' => $txData]; $this->paystackClient->expects($this->once()) @@ -66,15 +122,7 @@ public function testVerifyPaymentSuccessful(): void ->with('PSK_abc123') ->willReturn($apiResponse); - $lastOrder = $this->createMock(\Magento\Sales\Model\Order::class); - $lastOrder->method('getIncrementId')->willReturn('000000001'); - $this->checkoutSession->method('getLastRealOrder')->willReturn($lastOrder); - - $order = $this->createMock(\Magento\Sales\Model\Order::class); - $order->method('getQuoteId')->willReturn($quoteId); - $this->orderInterface->method('loadByIncrementId') - ->with('000000001') - ->willReturn($order); + $order = $this->stubMatchingOrder($quoteId); $this->eventManager->expects($this->once()) ->method('dispatch') @@ -84,6 +132,9 @@ public function testVerifyPaymentSuccessful(): void $this->assertTrue($result['status']); $this->assertEquals('Verification successful', $result['message']); + $this->assertSame(['status', 'reference'], array_keys($result['data'])); + $this->assertEquals('success', $result['data']['status']); + $this->assertEquals('PSK_abc123', $result['data']['reference']); } public function testVerifyPaymentQuoteIdMismatch(): void @@ -112,7 +163,20 @@ public function testVerifyPaymentQuoteIdMismatch(): void $result = json_decode($this->paymentManagement->verifyPayment($reference), true); $this->assertFalse($result['status']); - $this->assertStringContainsString("quoteId doesn't match", $result['message']); + $this->assertEquals('quote_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + // 'quote_mismatch' is not on TransactionValidator's explicit retry-safe + // list, so customerMessage() falls into the same fail-closed default + // branch as any other unrecognised reason. This changes the raw JSON + // `message` text (was the shorter "Payment could not be verified.") + // but is NOT a customer-visible behavior change: the inline JS was + // already terminal-by-default for every reason except + // 'not_successful', and its old terminal-branch fallback literal was + // character-identical to this new copy — only the wire contract moved. + $this->assertEquals( + 'We could not confirm your payment. Please do not pay again — contact support with your order number.', + $result['message'] + ); } public function testVerifyPaymentOrderQuoteIdMismatch(): void @@ -141,21 +205,61 @@ public function testVerifyPaymentOrderQuoteIdMismatch(): void $result = json_decode($this->paymentManagement->verifyPayment($reference), true); $this->assertFalse($result['status']); + $this->assertEquals('quote_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + } + + /** + * Mirrors CallbackTest's equivalent case: once the settlement gate passes + * and the event is dispatched, a throw from the observer (or anything + * downstream of dispatch) must not be reported to the customer as a + * rejected payment — money already moved and the advance is under way. + */ + public function testVerifyPaymentEventManagerThrowsAfterDispatchReturnsSuccess(): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + $txData = $this->buildTxData('success', $quoteId); + $this->paystackClient->method('verifyTransaction')->willReturn((object) ['data' => $txData]); + + $this->stubMatchingOrder($quoteId); + + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->willThrowException(new \RuntimeException('observer blew up')); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertTrue($result['status'], 'Must not report the generic failure response once dispatched.'); + $this->assertArrayNotHasKey('reason', $result, 'Not the failureResponse() shape.'); + $this->assertStringContainsString('do not pay again', $result['message']); } public function testVerifyPaymentApiExceptionReturnsError(): void { $reference = 'bad_ref_-~-_42'; + $exceptionMessage = 'Transaction not found: raw gateway body leaked here'; $this->paystackClient->method('verifyTransaction') - ->willThrowException(new ApiException('Transaction not found')); + ->willThrowException(new ApiException($exceptionMessage)); $this->eventManager->expects($this->never())->method('dispatch'); $result = json_decode($this->paymentManagement->verifyPayment($reference), true); $this->assertFalse($result['status']); - $this->assertEquals('Transaction not found', $result['message']); + $this->assertEquals('error', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + // 'error' is not on the retry-safe list either, so it falls into + // customerMessage()'s same fail-closed default as 'quote_mismatch' + // above. Wire-contract change only, not a customer-visible one — see + // the comment on testVerifyPaymentQuoteIdMismatch. + $this->assertEquals( + 'We could not confirm your payment. Please do not pay again — contact support with your order number.', + $result['message'] + ); + $this->assertStringNotContainsString($exceptionMessage, json_encode($result)); } public function testVerifyPaymentNoLastOrderReturnsError(): void @@ -177,6 +281,8 @@ public function testVerifyPaymentNoLastOrderReturnsError(): void $result = json_decode($this->paymentManagement->verifyPayment($reference), true); $this->assertFalse($result['status']); + $this->assertEquals('quote_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); } public function testVerifyPaymentNoIncrementIdReturnsError(): void @@ -200,5 +306,175 @@ public function testVerifyPaymentNoIncrementIdReturnsError(): void $result = json_decode($this->paymentManagement->verifyPayment($reference), true); $this->assertFalse($result['status']); + $this->assertEquals('quote_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + } + + /** + * @dataProvider notSuccessfulStatusProvider + */ + public function testVerifyPaymentNotSuccessfulStatusRejected(string $status): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + $txData = $this->buildTxData($status, $quoteId); + $this->paystackClient->method('verifyTransaction')->willReturn((object) ['data' => $txData]); + + $this->stubMatchingOrder($quoteId); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertEquals('not_successful', $result['reason']); + $this->assertFalse($result['final'], 'Retry-safe: nothing was charged through us.'); + // Paystack's own record says nothing was charged for this reason — the + // one status-based reason customerMessage() invites a retry for. + $this->assertEquals('Your payment was not completed. Please try again.', $result['message']); + } + + public static function notSuccessfulStatusProvider(): array + { + return [ + 'abandoned' => ['abandoned'], + 'failed' => ['failed'], + ]; + } + + /** + * @dataProvider inFlightStatusProvider + */ + public function testVerifyPaymentInFlightStatusRejected(string $status): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + $txData = $this->buildTxData($status, $quoteId); + $this->paystackClient->method('verifyTransaction')->willReturn((object) ['data' => $txData]); + + $this->stubMatchingOrder($quoteId); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertEquals('in_flight', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + $this->assertStringContainsString('do not pay again', $result['message']); + } + + public static function inFlightStatusProvider(): array + { + return [ + 'pending' => ['pending'], + 'ongoing' => ['ongoing'], + 'queued' => ['queued'], + ]; + } + + public function testVerifyPaymentAmountBelowToleranceWindowRejected(): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + // Order expects 500000 subunits (grand total 5000.00); paid short of the + // ±1-subunit tolerance window. + $txData = $this->buildTxData('success', $quoteId, 499998, 'NGN'); + $this->paystackClient->method('verifyTransaction')->willReturn((object) ['data' => $txData]); + + $this->stubMatchingOrder($quoteId, 5000.00, 'NGN'); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertEquals('amount_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + } + + public function testVerifyPaymentCurrencyMismatchRejected(): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + $txData = $this->buildTxData('success', $quoteId, 500000, 'USD'); + $this->paystackClient->method('verifyTransaction')->willReturn((object) ['data' => $txData]); + + $this->stubMatchingOrder($quoteId, 5000.00, 'NGN'); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertEquals('currency_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + } + + /** + * A verify response with no `metadata` at all must fail closed to + * quote_mismatch, not emit an "Undefined property"/"Attempt to read property + * on null" warning — Magento developer mode escalates warnings to + * exceptions, so this would otherwise 500 instead of returning JSON. + */ + public function testVerifyPaymentMissingMetadataReturnsQuoteMismatchWithoutWarning(): void + { + $reference = 'PSK_abc123_-~-_42'; + + $txData = (object) [ + 'status' => 'success', + 'reference' => 'PSK_abc123', + 'amount' => 500000, + 'currency' => 'NGN', + ]; + $this->paystackClient->method('verifyTransaction') + ->willReturn((object) ['data' => $txData]); + + $this->stubMatchingOrder('42'); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $warnings = []; + set_error_handler(function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + return true; + }, E_WARNING | E_NOTICE | E_DEPRECATED); + + try { + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings, 'A verify response with no `metadata` must not emit PHP warnings/notices.'); + $this->assertFalse($result['status']); + $this->assertEquals('quote_mismatch', $result['reason']); + $this->assertTrue($result['final'], 'Terminal: the customer must not be invited to pay again.'); + } + + /** + * The `_-~-_` split guard fires before any verify call — nothing was + * charged through us — so this now reports REASON_BAD_REFERENCE, not + * REASON_MALFORMED (which is reserved for an unreadable *gateway* verify + * response, a case where money's fate is unknown). Deliberate change: the + * copy also gains "Please try again." since bad_reference is retry-safe. + */ + public function testVerifyPaymentMalformedReferenceReturnsGenericFailure(): void + { + $reference = 'no-separator-reference'; + + $this->paystackClient->expects($this->never())->method('verifyTransaction'); + $this->eventManager->expects($this->never())->method('dispatch'); + + $result = json_decode($this->paymentManagement->verifyPayment($reference), true); + + $this->assertFalse($result['status']); + $this->assertEquals('bad_reference', $result['reason']); + $this->assertFalse($result['final'], 'Retry-safe: nothing was charged through us.'); + $this->assertEquals('Payment could not be verified. Please try again.', $result['message']); } } diff --git a/etc/webapi_rest/events.xml b/etc/webapi_rest/events.xml new file mode 100644 index 0000000..dc83218 --- /dev/null +++ b/etc/webapi_rest/events.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/marketplace/src/reference-manual.md b/marketplace/src/reference-manual.md index b3bb754..8a40904 100644 --- a/marketplace/src/reference-manual.md +++ b/marketplace/src/reference-manual.md @@ -55,7 +55,7 @@ The module registers the front name `paystack` (route id `pstk_paystack`). |---|---|---| | `/paystack/payment/setup` | `Controller\Payment\Setup` | Initialises a transaction and redirects to Paystack (Redirect flow) | | `/paystack/payment/callback` | `Controller\Payment\Callback` | Return URL from Paystack; verifies the transaction | -| `/paystack/payment/recreate` | `Controller\Payment\Recreate` | Cancels a failed order, restores the quote, returns to the payment step | +| `/paystack/payment/recreate` | `Controller\Payment\Recreate` | Cancels an unpaid order, restores the quote, returns to the payment step. Acts only on an order still in the `new` or `pending_payment` state that was placed with this payment method; anything else is refused, so a settled order cannot be cancelled by this route | | `/paystack/payment/webhook` | `Controller\Payment\Webhook` | Receives server-to-server events from Paystack | `Controller\Payment\AbstractPaystackStandard` provides shared quote-loading and messaging used by the Redirect-flow controllers. @@ -66,10 +66,28 @@ The module registers the front name `paystack` (route id `pstk_paystack`). |---|---|---| | GET | `/V1/paystack/verify/:reference` | anonymous | -Serviced by `Pstk\Paystack\Api\PaymentManagementInterface::verifyPayment`. The `reference` segment carries both the Paystack transaction reference and the Magento quote id, joined by an underscore: `{paystackReference}_{quoteId}`. +Serviced by `Pstk\Paystack\Api\PaymentManagementInterface::verifyPayment`. The `reference` segment carries both the Paystack transaction reference and the Magento quote id, joined by the literal separator `_-~-_`: `{paystackReference}_-~-_{quoteId}`. A reference that does not split into exactly two non-empty parts is rejected outright. The endpoint is anonymous because it is called by the checkout JavaScript immediately after the customer completes an inline payment, before any session guarantee exists. Verification is performed server-side against the Paystack API — the client's claim that payment succeeded is never trusted. +On success the response is `{"status": true, "message": ..., "data": {"status": ..., "reference": ...}}`. The `data` node deliberately carries only those two fields; the full Paystack transaction object is never returned to the browser. + +A refusal is `{"status": false, "reason": ..., "final": ..., "message": ...}`, where `reason` is a stable machine-readable code and `final` states whether the customer may be invited to pay again. `final` is `true` whenever money has moved or its fate is unknown — the checkout JavaScript must leave the Place Order button disabled in that case, and treats a missing or unrecognised `final` as `true` so that a new `reason` can never silently re-enable payment. + +| `reason` | Meaning | `final` | +|---|---|---| +| `not_successful` | Paystack's record shows the transaction did not succeed | `false` | +| `bad_reference` | The reference in the URL was unusable; no verification was attempted | `false` | +| `in_flight` | Still settling (bank transfer, USSD) — the webhook will confirm it | `true` | +| `amount_mismatch` | The paid amount (or, where Paystack reports it, the amount requested at initialize) did not settle the order total | `true` | +| `currency_mismatch` | Paid in a different currency than the order | `true` | +| `zero_total` | The order total or the paid amount was not a positive figure | `true` | +| `wrong_method` | The order was not placed with this payment method | `true` | +| `mode_mismatch` | The transaction's live/test `domain` did not match this store's configured mode | `true` | +| `malformed` | Paystack's verify response could not be read | `true` | +| `quote_mismatch` | The transaction does not belong to this order's quote | `true` | +| `error` | Verification could not be completed | `true` | + ## Events | Event | Observer | Effect | @@ -77,16 +95,31 @@ The endpoint is anonymous because it is called by the checkout JavaScript immedi | `sales_order_place_before` | `Observer\ObserverBeforeSalesOrderPlace` | Suppresses the initial order confirmation email | | `paystack_payment_verify_after` | `Observer\ObserverAfterPaymentVerify` | Sets the order to Processing and sends the confirmation email | -`paystack_payment_verify_after` is a custom event dispatched by this module. It is the single point at which order status advances and the confirmation email is sent, and it is dispatched by all three verification paths — inline REST verification, Redirect callback, and webhook. Observe it if you need to hook your own logic to confirmed payment. +`paystack_payment_verify_after` is a custom event dispatched by this module. It is the single point at which order status advances and the confirmation email is sent, and it is dispatched by all three verification paths — inline REST verification, Redirect callback, and webhook. Observe it if you need to hook your own logic to confirmed payment. It is registered in both `etc/frontend/events.xml` (Redirect callback, webhook) and `etc/webapi_rest/events.xml` (the inline REST verification endpoint, which runs in its own DI area) — both registrations are required for every verification path to reach the observer. + +`sales_order_place_before` is likewise registered in both areas: `ObserverBeforeSalesOrderPlace` suppresses the initial placement confirmation email until payment verifies, and the inline REST verification endpoint places its order in the `webapi_rest` area just as Redirect callback and webhook place theirs in `frontend` — without both registrations, the inline flow's placement email would never be suppressed while the `paystack_payment_verify_after` registration above still advanced the order, producing a duplicate confirmation email. ## Webhook Paystack sends `charge.success` events to `/paystack/payment/webhook` as an HTTP POST. -Each request carries an `X-Paystack-Signature` header containing an HMAC-SHA512 digest of the raw request body, computed with your Paystack secret key. The module validates this signature with a timing-safe comparison before processing, and rejects requests that fail. The transaction is then independently verified against the Paystack API. +Each request carries an `X-Paystack-Signature` header containing an HMAC-SHA512 digest of the raw request body, computed with your Paystack secret key. The module validates this signature with a timing-safe comparison before processing, and rejects requests that fail; a store with no secret key configured for the active mode fails signature validation outright rather than accepting any payload. The transaction is then independently verified against the Paystack API. Magento's form-key CSRF validation is skipped for this route via `Plugin\CsrfValidatorSkip`, because the request originates from Paystack rather than from a browser session. Signature validation is what authenticates the request. +### Retry semantics + +The HTTP status the endpoint returns tells Paystack whether redelivering the event could produce a different outcome, so the two classes must not be confused: + +- **`200`** — decided; do not retry. Returned when the transaction genuinely did not settle the order (not successful, amount or currency mismatch, non-positive total), when the payload cannot be parsed or carries no usable reference, when the event type is not one this module handles, and when an order cannot be found for a transaction old enough that no order is ever going to appear for it. +- **`503`** — undecided; please retry. Returned while a transaction is still settling, when Paystack's verify response could not be read, when the Paystack API call itself failed, and when the order has not been found yet but the transaction is recent enough that an in-flight order save could still be the cause. + +Any condition the module does not specifically recognise is treated as undecided and retried, because wrongly reporting a decision consumes Paystack's retry window and can leave a real payment permanently unconfirmed. + +When a settlement is refused, or accepted with an overpayment, the module records a comment on the order's status history giving the reason, the amount paid, the amount expected, and the transaction reference. The comment is written once per transaction and reason, so a retried event does not repeat it. + +The webhook's fallback lookup of an order by `quote_id` (used when the popup flow's Paystack-generated reference has no matching order) resolves an ambiguous match by requiring exactly one candidate order, not by disambiguating on amount. This is a known, separate limitation, not addressed by the retry-semantics change above. + ## Content Security Policy Magento enforces CSP on checkout and payment pages by default from 2.4.7 onward. The module ships `etc/csp_whitelist.xml`, the Magento-standard additive mechanism, which adds the following host sources without replacing your existing policy. @@ -116,7 +149,9 @@ Paystack generates the transaction reference on the client for inline payments, | File | Contents | |---|---| | `etc/frontend/di.xml` | Checkout config provider, `PaymentManagementInterface` preference, CSRF-skip plugin | +| `etc/frontend/events.xml` | `sales_order_place_before` and `paystack_payment_verify_after` observers | | `etc/webapi_rest/di.xml` | `PaymentManagementInterface` preference for REST calls | +| `etc/webapi_rest/events.xml` | `sales_order_place_before` and `paystack_payment_verify_after` observers — required so the inline REST verification endpoint's dispatch reaches them | | `etc/adminhtml/di.xml` | Intentionally empty | | `etc/di.xml` | Root scope, minimal | diff --git a/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js b/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js index 895ee20..fb115fc 100644 --- a/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/pstk_paystack-method.js @@ -21,6 +21,12 @@ define( ) { 'use strict'; + // Client-side fallback only: shown when the server's own tailored + // `message` is unavailable (parse failure, network failure, or a + // response that omits it). One copy, referenced everywhere it's needed. + var PAYMENT_UNCONFIRMED_MESSAGE = + "We could not confirm your payment. Please do not pay again — contact support with your order number."; + return Component.extend({ defaults: { template: 'Pstk_Paystack/payment/pstk_paystack' @@ -146,6 +152,11 @@ define( ] }, onSuccess: function (response) { + // Invariant: everything below this point runs AFTER Paystack has + // taken the customer's money, so this handler fails closed — only + // an explicit `data.final === false` from the verify response + // re-enables the pay button; re-enabling on anything else invites + // a double charge. fullScreenLoader.startLoader(); $.ajax({ method: "GET", @@ -160,37 +171,64 @@ define( } catch (e) { console.error('Payment verification JSON parse error:', e); fullScreenLoader.stopLoader(); - _this.isPlaceOrderActionAllowed(true); + // Can't prove the charge failed — terminal by default. _this.messageContainer.addErrorMessage({ - message: "Payment verification error." + message: PAYMENT_UNCONFIRMED_MESSAGE }); return; } - $.ajax({ - method: 'POST', - url: "https://plugin-tracker.paystackintegrations.com/log/charge_success", - data: { - plugin_name: 'magento-2', - transaction_reference: response.reference, - public_key: paystackConfiguration.public_key - } - }); - if (data.status && data.data && data.data.status === "success") { + // Only reported to Paystack's plugin tracker once + // verification actually succeeded — this call used to + // fire unconditionally, above, before the terminal- + // failure branch below was even evaluated, so a + // rejected settlement still reported a successful + // charge. + $.ajax({ + method: 'POST', + url: "https://plugin-tracker.paystackintegrations.com/log/charge_success", + data: { + plugin_name: 'magento-2', + transaction_reference: response.reference, + public_key: paystackConfiguration.public_key + } + }); + redirectOnSuccessAction.execute(); return; } + fullScreenLoader.stopLoader(); - _this.isPlaceOrderActionAllowed(true); + + if (data.final === false) { + // Only an explicit "not terminal" from the server re-enables + // payment. The server owns that classification (see + // TransactionValidator::isTerminalForCustomer) so a reason + // added there later cannot silently become retryable here. + // The comparison is against false, not a truthy check on + // `final`: a missing or mangled field must fail closed. + _this.isPlaceOrderActionAllowed(true); + _this.messageContainer.addErrorMessage({ + message: data.message || "Payment verification failed. Please try again." + }); + return; + } + + // Terminal: money moved, or its fate is unknown. Leave the + // button disabled — re-enabling it invites a double charge. + // The server always sends a tailored `message` for the reason + // it classified, so show that, falling back to a generic + // literal only if the server ever omits it. _this.messageContainer.addErrorMessage({ - message: "Payment verification failed. Status: " + (data.data ? data.data.status : 'unknown') + message: data.message || PAYMENT_UNCONFIRMED_MESSAGE }); }).fail(function () { fullScreenLoader.stopLoader(); - _this.isPlaceOrderActionAllowed(true); + // A 5xx/timeout on verify proves nothing about the charge — + // terminal by default. _this.messageContainer.addErrorMessage({ - message: "Payment verification failed." + message: PAYMENT_UNCONFIRMED_MESSAGE }); }); }, From 0f162655a07b5eeb143bf47c22ce372f1672f914 Mon Sep 17 00:00:00 2001 From: Jules Nsenda Date: Tue, 29 Sep 2026 12:11:51 +0200 Subject: [PATCH 3/3] feat: bind Paystack references to orders and register captures (Item C) Closes D7 (one charge settling two orders, narrowed to a race window -- still no reference-keyed lock, tracked as its own follow-up) and D8 (orders advancing with total_paid NULL, no invoice, no transaction row) via a new Model/PaymentSettlement::register(), wired into all three consumers in place of the direct TransactionValidator call. Collapses ObserverAfterPaymentVerify to email-only: registerCaptureNotification() flips order state as a side effect, so the observer no longer tries to do that itself, gating instead on !getEmailSent(). Went through a 2-critic panel that found a critical-adjacent issue in the first draft: register() returned only a rejection reason, so all three consumers kept dispatching their own stale, pre-capture $order instance rather than the settled one this class actually mutated and saved -- a third-party observer on the public paystack_payment_verify_after event doing an ordinary $order->save() could have silently reverted the capture. Fixed by returning {reason, order} and updating every dispatch site to use the settled instance. Also fixed: a TOCTOU-defeating exception fallback, an unguarded bind/register/save path with zero merchant-visible trace on failure, a reachable retry-classification bug (a late-settling charge against a since-cancelled order was being permanently dropped instead of retried), and an inconsistent reference source across the three consumers. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 40 +- CLAUDE.md | 11 +- .../Payment/AbstractPaystackStandard.php | 19 +- Controller/Payment/Callback.php | 24 +- Controller/Payment/Webhook.php | 144 ++--- Gateway/Validator/TransactionValidator.php | 41 ++ Model/PaymentManagement.php | 24 +- Model/PaymentSettlement.php | 408 ++++++++++++ Observer/ObserverAfterPaymentVerify.php | 42 +- Test/Unit/Controller/Payment/CallbackTest.php | 108 +++- Test/Unit/Controller/Payment/RecreateTest.php | 4 +- Test/Unit/Controller/Payment/SetupTest.php | 4 +- Test/Unit/Controller/Payment/WebhookTest.php | 174 +++++- .../Validator/TransactionValidatorTest.php | 30 + Test/Unit/Model/PaymentManagementTest.php | 95 ++- Test/Unit/Model/PaymentSettlementTest.php | 583 ++++++++++++++++++ .../ObserverAfterPaymentVerifyTest.php | 79 ++- etc/frontend/di.xml | 12 + marketplace/src/reference-manual.md | 4 +- 19 files changed, 1668 insertions(+), 178 deletions(-) create mode 100644 Model/PaymentSettlement.php create mode 100644 Test/Unit/Model/PaymentSettlementTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 294a8db..c023ff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,34 @@ what was requested. unsuppressed placement email, plus a new post-payment one); both are registered together. +### Security +- **A single Paystack reference can no longer settle two different orders + (D7, narrowed to a race window — not fully closed; see the reconciliation + plan's Risks section).** All three verification paths now bind the + reference to the order via a cross-order lookup before registering the + payment; a reference already bound to a different order is rejected + (`reference_bound_elsewhere`) rather than silently advancing whichever + order asks second. +- **Verified payments are now actually registered against the order** + (`total_paid`, an invoice, and a `sales_payment_transaction` row) — D8's + accounting half. Previously, every consumer dispatched + `paystack_payment_verify_after` without ever calling + `registerCaptureNotification()`, so a "Processing" order had no invoice and + no recorded payment. Registration is idempotent per (reference, order): a + repeat verify of an already-registered payment is a no-op, not a duplicate + capture. An order no longer in a payable state (canceled/closed) is + rejected (`order_not_payable`). + +### Changed +- **`Observer/ObserverAfterPaymentVerify.php` no longer advances the order + state itself.** `Model/PaymentSettlement::register()` (above) now owns that + side effect via `registerCaptureNotification()`; the observer is email-only, + gated on `!$order->getEmailSent()` instead of the order's status. The + human-readable "Paystack Payment Verified and Order is being processed" + history comment this observer used to write is gone — replaced by core's + own transaction-ID comment (`registerCaptureNotification()` → + `addTransactionCommentsToOrder()`), not an equivalent line. + ### Fixed - **Webhook responses now distinguish transient from permanent failures.** Transient conditions (transaction still settling via bank transfer/USSD, @@ -69,10 +97,14 @@ what was requested. silently cancelled retries and could permanently strand a legitimate payment's confirmation. Genuine rejections (failed status, amount/currency mismatch) return HTTP 200 so Paystack does not pointlessly retry. -- **Rejected and surplus payments are now visible to the merchant.** When the - (signature-verified) webhook rejects a settlement mismatch, or accepts an - overpayment, it writes an order status-history comment with the paid vs - expected amount and reference. +- **Rejected and surplus payments are now visible to the merchant on all three + paths, not only the webhook.** `Model/PaymentSettlement` writes an order + status-history comment (paid vs expected amount, reference) for every + settlement rejection and every overpayment, whichever of the redirect + callback, inline REST endpoint, or webhook produced it — previously only + the webhook recorded this, so a rejected callback/inline verification + (including a cross-order `reference_bound_elsewhere` hit) left zero + merchant-visible trace. - **A malformed inline verification reference no longer causes a 500** on the anonymous REST route. - **After a payment fails post-charge on the inline flow, the Place Order diff --git a/CLAUDE.md b/CLAUDE.md index 9bedd47..47edd54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,20 +108,20 @@ There are two integration types, selectable in admin config: **Inline (default)**: Paystack popup opens in the browser after order is placed. 1. JS calls `afterPlaceOrder()` → Paystack popup opens 2. On success, JS calls `GET /V1/paystack/verify/{reference}_{quoteId}` (REST API, anonymous) -3. `PaymentManagement::verifyPayment()` verifies with Paystack API, dispatches `paystack_payment_verify_after` -4. `ObserverAfterPaymentVerify` sets order to Processing and sends confirmation email +3. `PaymentManagement::verifyPayment()` verifies with Paystack API, calls `Model/PaymentSettlement::register()` (binds the reference, registers the payment, advances order state), then dispatches `paystack_payment_verify_after` +4. `ObserverAfterPaymentVerify` sends the confirmation email (email-only — see below) **Standard (redirect)**: Customer is redirected to Paystack's hosted page. 1. `/paystack/payment/setup` — initializes transaction, redirects to Paystack -2. `/paystack/payment/callback` — Paystack returns here; verifies transaction, dispatches `paystack_payment_verify_after` +2. `/paystack/payment/callback` — Paystack returns here; verifies transaction, calls `Model/PaymentSettlement::register()`, dispatches `paystack_payment_verify_after` 3. `/paystack/payment/recreate` — retry path: cancels the failed/abandoned order, restores the quote, and redirects back to the checkout payment step **Webhook** (independent, server-to-server): - `/paystack/payment/webhook` — receives `charge.success` events from Paystack -- Validates HMAC-SHA512 signature, verifies transaction, dispatches `paystack_payment_verify_after` +- Validates HMAC-SHA512 signature, verifies transaction, calls `Model/PaymentSettlement::register()`, dispatches `paystack_payment_verify_after` - CSRF validation skipped via `Plugin/CsrfValidatorSkip.php` -The custom event `paystack_payment_verify_after` is the single point where order status is updated to Processing and confirmation email is sent (`Observer/ObserverAfterPaymentVerify.php`). Initial order confirmation email is suppressed by `ObserverBeforeSalesOrderPlace` until payment is verified. Both observers must be registered in **every** DI area a checkout path can place/verify an order from — `Model/PaymentManagement.php` (the inline flow's default integration type) runs in `webapi_rest`, not `frontend`, so both `etc/frontend/events.xml` and `etc/webapi_rest/events.xml` register both events; registering only one observer in `webapi_rest` without the other caused either a missing post-payment confirmation (inline orders stuck unadvanced) or a duplicate confirmation email (placement email unsuppressed, post-payment email also sent). +`Model/PaymentSettlement::register()` is the single class that binds the Paystack reference to an order, registers the captured payment (`total_paid`, invoice, transaction row via `registerCaptureNotification()`), and records rejection history — for all three verification paths above, before any of them dispatches `paystack_payment_verify_after`. Each consumer dispatches the settled order instance `register()` returns, not its own stale one. `ObserverAfterPaymentVerify.php` no longer advances order state at all: it is email-only now, gated on `!$order->getEmailSent()` (register() already saved the order by the time the observer runs). Initial order confirmation email is suppressed by `ObserverBeforeSalesOrderPlace` until payment is verified. Both observers must be registered in **every** DI area a checkout path can place/verify an order from — `Model/PaymentManagement.php` (the inline flow's default integration type) runs in `webapi_rest`, not `frontend`, so both `etc/frontend/events.xml` and `etc/webapi_rest/events.xml` register both events; registering only one observer in `webapi_rest` without the other caused either a missing post-payment confirmation (inline orders stuck unadvanced) or a duplicate confirmation email (placement email unsuppressed, post-payment email also sent). ### Key Classes @@ -129,6 +129,7 @@ The custom event `paystack_payment_verify_after` is the single point where order |---|---| | `Gateway/PaystackApiClient.php` | All Paystack API calls: initialize transaction, verify, validate webhook signature | | `Model/PaymentManagement.php` | REST API endpoint for inline payment verification | +| `Model/PaymentSettlement.php` | Binds the Paystack reference to an order, registers the captured payment, and records rejection/overpayment history — shared by all three verification paths | | `Model/Ui/ConfigProvider.php` | Injects public key, integration type, and URLs into checkout JS config | | `Controller/Payment/AbstractPaystackStandard.php` | Base controller with shared utilities (quote loading, message handling) | | `etc/csp_whitelist.xml` | Whitelists Paystack domains in Magento's Content Security Policy (additive; the Magento-standard mechanism) | diff --git a/Controller/Payment/AbstractPaystackStandard.php b/Controller/Payment/AbstractPaystackStandard.php index 63bb89b..47884e1 100644 --- a/Controller/Payment/AbstractPaystackStandard.php +++ b/Controller/Payment/AbstractPaystackStandard.php @@ -88,6 +88,12 @@ abstract class AbstractPaystackStandard extends \Magento\Framework\App\Action\Ac */ protected $transactionValidator; + /** + * + * @var \Pstk\Paystack\Model\PaymentSettlement + */ + protected $paymentSettlement; + /** * Constructor * @@ -108,7 +114,8 @@ public function __construct( \Magento\Framework\App\Request\Http $request, \Psr\Log\LoggerInterface $logger, PaystackApiClient $paystackClient, - ?\Pstk\Paystack\Gateway\Validator\TransactionValidator $transactionValidator = null + ?\Pstk\Paystack\Gateway\Validator\TransactionValidator $transactionValidator = null, + ?\Pstk\Paystack\Model\PaymentSettlement $paymentSettlement = null ) { $this->resultPageFactory = $resultPageFactory; $this->orderRepository = $orderRepository; @@ -131,6 +138,16 @@ public function __construct( $this->transactionValidator = $transactionValidator ?: \Magento\Framework\App\ObjectManager::getInstance() ->get(\Pstk\Paystack\Gateway\Validator\TransactionValidator::class); + // Same nullable+ObjectManager-fallback pattern, same BC reasoning — + // wired in via a \Pstk\Paystack\Model\PaymentSettlement\Proxy in + // etc/frontend/di.xml so Setup.php/Recreate.php, which never call it, + // don't eagerly build its dependency graph. Only TransactionRepositoryInterface + // and SearchCriteriaBuilder are actually deferred by the Proxy — + // OrderRepositoryInterface is already an eager, direct constructor + // dependency of this class itself, so the Proxy buys it nothing. + $this->paymentSettlement = $paymentSettlement + ?: \Magento\Framework\App\ObjectManager::getInstance() + ->get(\Pstk\Paystack\Model\PaymentSettlement::class); parent::__construct($context); } diff --git a/Controller/Payment/Callback.php b/Controller/Payment/Callback.php index 2036fc6..7449f1c 100644 --- a/Controller/Payment/Callback.php +++ b/Controller/Payment/Callback.php @@ -100,15 +100,21 @@ public function execute() { if ($order && $reference === $order->getIncrementId()) { // The status gate above only confirms `success`; it says nothing about // whether the amount/currency paid actually settles this order, or - // whether the order was even placed with Paystack. That double coverage - // is deliberate — this is the one check that closes those gaps, and it - // must run before $dispatched is set so a mismatch never reaches the - // "payment received" warning branch below. - $failureReason = $this->transactionValidator->settlementFailureReason( + // whether the order was even placed with Paystack, or whether this + // exact reference already belongs to a different order. That coverage + // is deliberate — register() is the one call that closes those gaps, + // and it must run before $dispatched is set so a mismatch never reaches + // the "payment received" warning branch below. + $registration = $this->paymentSettlement->register( $transactionDetails, $order, $this->paystackClient->isTestMode() ); + $failureReason = $registration['reason']; + // The settled instance register() actually mutated and + // saved — not the stale $order looked up above — so the + // event below reads/reports post-capture state. + $order = $registration['order']; if (null !== $failureReason) { $this->logger->warning( @@ -172,11 +178,11 @@ public function execute() { } if ($dispatched) { - // Verification succeeded and the advance was already under way when something - // downstream threw — the observer saves the order before it sends the email. + // Verification succeeded and the save already happened inside + // PaymentSettlement::register() before the event was dispatched. // Showing the failure page here would present a paid order as failed and - // invite a second payment; the warning covers the narrower case where the - // throw came before the save, so the order is paid but not yet advanced. + // invite a second payment; the warning covers the narrower case where + // something downstream of the dispatch (e.g. the confirmation email) threw. $this->messageManager->addWarningMessage( __("Your payment was received, but we could not finish updating your " . "order. Please do not pay again — contact support if you do not " diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index 0c32e94..68c3db2 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -41,15 +41,34 @@ class Webhook extends AbstractPaystackStandard /** * Reasons deliberately excluded from TransactionValidator::PERMANENT_FOR_WEBHOOK * (see its own comments) that must also never fall back to permanent once - * ORDER_LOOKUP_RETRY_WINDOW_SECONDS elapses — a swapped key pair or a wrong - * payment method on this order is never fixed by the passage of time, so - * unlike MALFORMED/unrecognised reasons (which do fall back to permanent - * below), these two keep retrying (503) indefinitely, the same treatment - * REASON_IN_FLIGHT gets. + * ORDER_LOOKUP_RETRY_WINDOW_SECONDS elapses — a swapped key pair, a wrong + * payment method on this order, an order that is not payable yet (or + * anymore), or a transient failure while registering a passed settlement + * is never fixed by the passage of time in the way MALFORMED/unrecognised + * reasons might be, so these keep retrying (503) indefinitely, the same + * treatment REASON_IN_FLIGHT gets: + * - REASON_MODE_MISMATCH / REASON_WRONG_METHOD: the misconfiguration or + * method mixup is never fixed by a retry either, but recording it here + * (rather than as permanent) keeps the retry window open in case the + * underlying relation was simply not yet hydrated. + * - REASON_ORDER_NOT_PAYABLE: a bank-transfer/USSD charge that is + * genuinely `pending` at callback time can settle minutes later via + * this webhook — if the customer used `/paystack/payment/recreate` in + * the meantime, the order is now `canceled`, and a late but genuine + * `charge.success` must not be permanently dropped (money captured + * with no order and no refund path). Deliberately NOT + * PERMANENT_FOR_WEBHOOK, unlike REASON_REFERENCE_BOUND_ELSEWHERE, which + * is not time-dependent. + * - REASON_REGISTRATION_FAILED: a throw from PaymentSettlement::register()'s + * own bind/register/save steps after every check already passed — a + * transient DB/invoice issue should keep retrying, since money may + * already have moved. */ private const NEVER_RECENCY_BOUNDED = [ TransactionValidator::REASON_MODE_MISMATCH, TransactionValidator::REASON_WRONG_METHOD, + TransactionValidator::REASON_ORDER_NOT_PAYABLE, + TransactionValidator::REASON_REGISTRATION_FAILED, ]; public function execute() { @@ -180,9 +199,11 @@ private function respond($result, int $code, string $body) } /** - * Decides the webhook response for a matched order: re-verifies settlement, - * dispatches and logs the surplus on success, records merchant-visible - * history on rejection. Needs no result object — a pure decision function. + * Decides the webhook response for a matched order: registers the + * settlement (binding + capture) via PaymentSettlement, dispatches on + * success. Rejection history is written by PaymentSettlement itself now + * (uniformly across all three consumers), not here. Needs no result + * object — a pure decision function. * * @param object $transactionDetails Full envelope PaystackApiClient::verifyTransaction() returns * @param OrderInterface $order @@ -191,17 +212,21 @@ private function respond($result, int $code, string $body) */ private function resolveSettlement(object $transactionDetails, OrderInterface $order, string $reference): array { - // The webhook payload's own status only got us this far; the - // re-verified response is the trustworthy statement of what - // actually happened to the money — nothing may advance the - // order until it settles this order, in this currency, for at - // least this amount. - $reason = $this->transactionValidator->settlementFailureReason( + // The webhook payload's own status only got us this far; register() + // re-verifies from scratch against a freshly re-fetched order — nothing + // may advance the order until it settles this order, in this currency, + // for at least this amount, and this exact reference isn't already + // bound to a different order. + $registration = $this->paymentSettlement->register( $transactionDetails, $order, $this->paystackClient->isTestMode() ); - $expectedSubunits = $this->transactionValidator->expectedSubunits($order); + $reason = $registration['reason']; + // The settled instance register() actually mutated and saved — not + // the stale $order this method was called with — so every use below + // (the log, and the event dispatch) reads/reports post-capture state. + $order = $registration['order']; if (null === $reason) { $this->logger->info("Paystack Webhook: order found, dispatching verify event", [ @@ -214,22 +239,6 @@ private function resolveSettlement(object $transactionDetails, OrderInterface $o // charge succeeded even for rejected settlements. $this->paystackClient->logTransactionSuccess($reference, $this->configProvider->getPublicKey()); - if ($this->transactionValidator->isOverpayment($transactionDetails, $order)) { - // isOverpayment() returning true guarantees paidSubunits() is non-null. - $paidAmount = $this->transactionValidator->paidSubunits($transactionDetails); - $this->recordHistory( - $order, - $reference, - 'overpaid', - sprintf( - 'Paystack: payment overpaid — paid %d, expected %d, reference %s', - $paidAmount, - $expectedSubunits, - $reference - ) - ); - } - // dispatch the `payment_verify_after` event to update the order status $this->eventManager->dispatch('paystack_payment_verify_after', [ "paystack_order" => $order, @@ -253,28 +262,6 @@ private function resolveSettlement(object $transactionDetails, OrderInterface $o // are safe to report permanent. $isPermanentReason = $this->transactionValidator->isPermanentForWebhook($reason); - // Signature-verified path: every real charge fires this webhook, so - // this is the merchant's only visibility into "money moved but the - // order did not advance". Dedupes via historyMarker() so a transient - // reason retried for up to 72h doesn't append a comment every time. - $paidAmount = $this->transactionValidator->paidSubunits($transactionDetails) ?? 'unknown'; - $paidCurrency = $transactionDetails->data->currency ?? 'unknown'; - $this->recordHistory( - $order, - $reference, - $reason, - sprintf( - 'Paystack: payment %s — %s: paid %s %s, expected %s %s, reference %s', - $isPermanentReason ? 'rejected' : 'not applied (retry pending)', - $reason, - $paidAmount, - $paidCurrency, - $expectedSubunits, - $order->getOrderCurrencyCode(), - $reference - ) - ); - if ($isPermanentReason) { return [200, "rejected"]; } @@ -324,57 +311,6 @@ private function resolveOrderNotFound(object $transactionDetails, string $refere return [200, "order not found"]; } - /** - * The stable (reference, reason) marker embedded in a history comment — - * the single definition recordHistory() writes and dedupes against, so - * the human-readable prose around it can be reworded without breaking - * dedupe. - * - * @param string $reference - * @param string $reasonKey - * @return string - */ - private function historyMarker(string $reference, string $reasonKey): string - { - return sprintf('[paystack:%s:%s]', $reference, $reasonKey); - } - - /** - * Writes a merchant-visible order-history comment, but only once per - * (reference, reason) pair: WRONG_METHOD and MALFORMED are transient (503) - * and Paystack's ~72h retry window would otherwise append a near-identical - * comment on every retry of the same rejection. A failed write, and a - * failed lookup of the existing history, must not turn a rejection/ - * overpayment note into a 503 retry storm — log and continue. - * - * @param OrderInterface $order - * @param string $reference - * @param string $reasonKey - * @param string $comment - * @return void - */ - private function recordHistory(OrderInterface $order, string $reference, string $reasonKey, string $comment): void - { - $marker = $this->historyMarker($reference, $reasonKey); - try { - foreach ($order->getStatusHistories() ?? [] as $history) { - $existingComment = $history->getComment() ?? ''; - if (strpos($existingComment, $marker) !== false) { - // Already recorded for this (reference, reason) pair — this is a - // retry of an event we already wrote a comment for. - return; - } - } - - $order->addStatusToHistory($order->getStatus(), $comment . ' ' . $marker); - $this->orderRepository->save($order); - } catch (\Throwable $exc) { - // A failed history write must not turn an already-decided - // rejection into a 503 retry that can never fix it — log and continue. - $this->logger->error("Paystack Webhook: failed to write order history", ['error' => $exc->getMessage()]); - } - } - /** * Whether a verify response's transaction is recent enough that "no matching * order" should still be treated as transient. Reads data.paid_at, falling back diff --git a/Gateway/Validator/TransactionValidator.php b/Gateway/Validator/TransactionValidator.php index dffc369..9c5c16a 100644 --- a/Gateway/Validator/TransactionValidator.php +++ b/Gateway/Validator/TransactionValidator.php @@ -45,6 +45,44 @@ class TransactionValidator public const REASON_AMOUNT_MISMATCH = 'amount_mismatch'; public const REASON_MODE_MISMATCH = 'mode_mismatch'; + /** + * A reference already bound (via a persisted sales_payment_transaction + * row) to a *different* order — the D7 cross-order attack. Money moved, + * definitively, just not attributable to this order; never fixed by a + * retry, so it is PERMANENT_FOR_WEBHOOK and never RETRYABLE_FOR_CUSTOMER. + * Lives here, not on Model/PaymentSettlement.php (the class that actually + * runs the binding check), because keeping every REASON_* constant and its + * classification in one place matters more than which class detects it. + */ + public const REASON_REFERENCE_BOUND_ELSEWHERE = 'reference_bound_elsewhere'; + + /** + * The order is not in a state (STATE_NEW/STATE_PENDING_PAYMENT) that can + * still be registered as paid — e.g. canceled/closed. Never + * RETRYABLE_FOR_CUSTOMER (money moved or the situation is otherwise + * unrecoverable by retry — fail closed there), but deliberately NOT + * PERMANENT_FOR_WEBHOOK, unlike REASON_REFERENCE_BOUND_ELSEWHERE: a + * bank-transfer/USSD charge that is genuinely `pending` at callback time + * can settle minutes later via the webhook, and if the customer used + * `/paystack/payment/recreate` in the meantime the order is now + * `canceled` — a late but genuine `charge.success` must keep retrying + * (Webhook.php's own `NEVER_RECENCY_BOUNDED`), not be permanently + * dropped, or the money is captured with no order and no refund path. + */ + public const REASON_ORDER_NOT_PAYABLE = 'order_not_payable'; + + /** + * A throw from `Model/PaymentSettlement::register()`'s own bind/register/ + * save steps, after every validation and binding check already passed — + * e.g. a `LocalizedException` from `prepareInvoice()` on some order + * shape, or a transient DB failure on save. Money may already have moved + * by this point, so never RETRYABLE_FOR_CUSTOMER; not PERMANENT_FOR_WEBHOOK + * either (Webhook.php's `NEVER_RECENCY_BOUNDED`) — a transient + * registration failure should keep retrying, since the same event + * redelivered later may succeed once the underlying issue clears. + */ + public const REASON_REGISTRATION_FAILED = 'registration_failed'; + /** * Tolerance above the *requested* subunit amount (see the two-field * overpayment check in settlementFailureReason()). Covers orders paid by @@ -89,6 +127,7 @@ class TransactionValidator self::REASON_AMOUNT_MISMATCH, self::REASON_CURRENCY_MISMATCH, self::REASON_ZERO_TOTAL, + self::REASON_REFERENCE_BOUND_ELSEWHERE, ]; /** @var LoggerInterface */ @@ -182,6 +221,8 @@ private function requestedSubunits(object $verifyResponse): ?int } /** + * @internal Direct use skips reference binding and payment registration — + * callers should use `Model\PaymentSettlement::register()` instead. * @param object $verifyResponse Full envelope PaystackApiClient::verifyTransaction() returns * @param OrderInterface $order * @param bool $testMode Whether the store is configured for Paystack test diff --git a/Model/PaymentManagement.php b/Model/PaymentManagement.php index 29cf6dd..b5e4dbb 100644 --- a/Model/PaymentManagement.php +++ b/Model/PaymentManagement.php @@ -49,13 +49,19 @@ class PaymentManagement implements \Pstk\Paystack\Api\PaymentManagementInterface */ private $transactionValidator; + /** + * @var \Pstk\Paystack\Model\PaymentSettlement + */ + private $paymentSettlement; + public function __construct( PaystackApiClient $paystackClient, \Magento\Framework\Event\Manager $eventManager, \Magento\Sales\Api\Data\OrderInterface $orderInterface, \Magento\Checkout\Model\Session $checkoutSession, LoggerInterface $logger, - TransactionValidator $transactionValidator + TransactionValidator $transactionValidator, + \Pstk\Paystack\Model\PaymentSettlement $paymentSettlement ) { $this->paystackClient = $paystackClient; $this->eventManager = $eventManager; @@ -63,6 +69,7 @@ public function __construct( $this->checkoutSession = $checkoutSession; $this->logger = $logger; $this->transactionValidator = $transactionValidator; + $this->paymentSettlement = $paymentSettlement; } /** @@ -110,11 +117,16 @@ public function verifyPayment($reference) if ($order && (string)$order->getQuoteId() === (string)$quoteId && (string)($transaction_details->data->metadata->quoteId ?? null) === (string)$quoteId) { - $failureReason = $this->transactionValidator->settlementFailureReason( + $registration = $this->paymentSettlement->register( $transaction_details, $order, $this->paystackClient->isTestMode() ); + $failureReason = $registration['reason']; + // The settled instance register() actually mutated and + // saved — not the stale $order looked up above — so the + // event below reads/reports post-capture state. + $order = $registration['order']; if ($failureReason === null) { // dispatch the `paystack_payment_verify_after` event to update the order status @@ -149,10 +161,10 @@ public function verifyPayment($reference) $this->logger->error('Paystack: verifyPayment exception', ['error' => $e->getMessage()]); if ($dispatched) { - // Verification succeeded and the advance was already under way when - // something downstream threw — the observer saves the order before it - // sends the email. Reporting the generic failure response here would - // present a paid order as rejected and invite a second payment. + // Verification succeeded and the save already happened inside + // PaymentSettlement::register() before the event was dispatched. + // Reporting the generic failure response here would present a paid + // order as rejected and invite a second payment. $this->logger->warning( 'Paystack: verifyPayment exception after settlement dispatched — reporting success', ['reference' => $reference] diff --git a/Model/PaymentSettlement.php b/Model/PaymentSettlement.php new file mode 100644 index 0000000..8c9fdc1 --- /dev/null +++ b/Model/PaymentSettlement.php @@ -0,0 +1,408 @@ + you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see //www.gnu.org/licenses/>. + */ + +namespace Pstk\Paystack\Model; + +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Exception\InputException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Api\TransactionRepositoryInterface; +use Magento\Sales\Model\Order; +use Pstk\Paystack\Gateway\Validator\TransactionValidator; +use Psr\Log\LoggerInterface; + +/** + * Binds a Paystack reference to an order (D7 — closes "one charge settles two + * orders" to a race window, not fully — see the parent plan's Risks section) + * and registers the captured payment (D8 — `total_paid`, invoice, transaction + * row), or rejects and records why. + * + * Deliberately does NOT dispatch `paystack_payment_verify_after` — that stays + * owned by each of the three consumers, which already hold (and pass) their + * own `$order` instance to the event; duplicating that here would mean this + * class needs an event manager dependency it otherwise has no reason to hold. + * + * Every history comment this class writes directly is a static string built + * from allow-listed scalars only — never the raw verify response — for the + * same stored-XSS reason `TransactionValidator`'s own docblock gives: admin's + * order status-history renderer permits a subset of HTML tags. + */ +class PaymentSettlement +{ + /** @var TransactionValidator */ + private $transactionValidator; + + /** @var OrderRepositoryInterface */ + private $orderRepository; + + /** @var TransactionRepositoryInterface */ + private $transactionRepository; + + /** @var SearchCriteriaBuilder */ + private $searchCriteriaBuilder; + + /** @var LoggerInterface */ + private $logger; + + public function __construct( + TransactionValidator $transactionValidator, + OrderRepositoryInterface $orderRepository, + TransactionRepositoryInterface $transactionRepository, + SearchCriteriaBuilder $searchCriteriaBuilder, + LoggerInterface $logger + ) { + $this->transactionValidator = $transactionValidator; + $this->orderRepository = $orderRepository; + $this->transactionRepository = $transactionRepository; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->logger = $logger; + } + + /** + * @param object $transactionDetails Full envelope PaystackApiClient::verifyTransaction() returns + * @param OrderInterface $order Caller's own instance — re-fetched fresh + * internally rather than trusted, since the caller only validated + * `settlementFailureReason()` against *its* instance, not against + * whatever else may have written to this order since. + * @param bool $testMode Must come from `PaystackApiClient::isTestMode()`. + * @return array{reason: ?string, order: OrderInterface} `reason` is null + * once bound/registered (or already idempotently bound); otherwise + * the first failing REASON_* constant. `order` is the settled + * instance this method actually mutated and saved (or, when nothing + * could safely be validated/registered against, the best available + * instance) — callers must dispatch/reference THIS instance, not + * their own stale parameter, since it may have `total_paid`, an + * invoice, and a transaction row this call just added that the + * caller's own instance does not. + */ + public function register(object $transactionDetails, OrderInterface $order, bool $testMode): array + { + // The reference is both the binding key (D7) and the idempotency key, + // so it is derived here, once, from Paystack's own echoed value — + // never from a caller-supplied fragment (e.g. the client-supplied + // REST path segment) — so all three consumers bind against the exact + // same value. Same guard Webhook.php already applies to this field. + $data = $transactionDetails->data ?? null; + $reference = is_object($data) ? ($data->reference ?? null) : null; + if (!is_string($reference) || $reference === '') { + $this->logger->warning('Paystack: settlement registration missing readable reference', [ + 'order_increment_id' => $order->getIncrementId(), + ]); + return ['reason' => TransactionValidator::REASON_MALFORMED, 'order' => $order]; + } + + // Step 1: re-fetch fresh, guard the type, re-verify from scratch — + // closes the TOCTOU gap: the caller validated settlementFailureReason() + // against its own $order instance, which this method must not trust. + try { + $freshOrder = $this->orderRepository->get($order->getEntityId()); + } catch (NoSuchEntityException | InputException $exc) { + // A definite negative answer — the repository is certain this + // order does not exist (or the id is unusable) — not a transient + // failure, so this must NOT fall back to the caller's own, + // unvalidated-by-the-repository $order below. + $this->logger->warning('Paystack: settlement registration order lookup came back definitively negative', [ + 'reference' => $reference, + 'order_increment_id' => $order->getIncrementId(), + ]); + return ['reason' => TransactionValidator::REASON_MALFORMED, 'order' => $order]; + } catch (\Throwable $exc) { + // Genuinely transient (e.g. a DB hiccup) — fall through to the + // type guard below, which falls back to the caller's own concrete + // instance if it has one. + $freshOrder = null; + } + + if (!$freshOrder instanceof Order) { + $freshOrder = $order instanceof Order ? $order : null; + } + + if ($freshOrder === null) { + // No usable concrete order to validate, register, or write a + // rejection comment against — cannot safely proceed, and cannot + // record history either (there is nothing to write it to). + $this->logger->warning('Paystack: settlement registration could not obtain a usable order instance', [ + 'reference' => $reference, + ]); + return ['reason' => TransactionValidator::REASON_MALFORMED, 'order' => $order]; + } + + $reason = $this->transactionValidator->settlementFailureReason($transactionDetails, $freshOrder, $testMode); + if ($reason !== null) { + // REASON_IN_FLIGHT is not a rejection — it means the charge is + // still settling (bank transfer/USSD) and Paystack (or the + // customer) will be back. Recording it as merchant-visible + // history on every retry of an event that resolves on its own + // would be noise, not signal — carried over from the deny-list + // behavior Webhook.php's own recordHistory() used to have before + // this was hoisted here. + if ($reason !== TransactionValidator::REASON_IN_FLIGHT) { + $this->recordRejection($freshOrder, $reference, $reason, $transactionDetails); + } + return ['reason' => $reason, 'order' => $freshOrder]; + } + + // Step 2: cross-order binding check — has this exact reference + // already been bound to a DIFFERENT order? + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter('txn_id', $reference, 'eq') + ->create(); + $existing = $this->transactionRepository->getList($searchCriteria)->getItems(); + + foreach ($existing as $item) { + if ((int) $item->getOrderId() !== (int) $freshOrder->getEntityId()) { + $this->recordRejection( + $freshOrder, + $reference, + TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, + $transactionDetails + ); + return ['reason' => TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, 'order' => $freshOrder]; + } + } + + // Step 3: same-order idempotent short-circuit — explicit compound key + // (txn_id === $reference AND order_id === $freshOrder's id), not a + // vaguer "already has a capture" key: a *different*, new reference + // against an already-registered order must not silently return + // "settled" for a charge that was never applied to anything. + foreach ($existing as $item) { + if ((int) $item->getOrderId() === (int) $freshOrder->getEntityId()) { + $this->logger->info('Paystack: idempotent re-verify, reference already bound to this order', [ + 'reference' => $reference, + 'order_increment_id' => $freshOrder->getIncrementId(), + ]); + return ['reason' => null, 'order' => $freshOrder]; + } + } + + // Step 4: order-state guard — only reached once binding/idempotency is + // resolved. registerCaptureNotification() flips state as a side + // effect, so a re-verify of an already-advanced order must hit step + // 3's no-op before this guard could mistake it for not-payable. + if (!in_array($freshOrder->getState(), [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT], true) + || $freshOrder->getBaseTotalDue() <= 0 + ) { + $this->recordRejection( + $freshOrder, + $reference, + TransactionValidator::REASON_ORDER_NOT_PAYABLE, + $transactionDetails + ); + return ['reason' => TransactionValidator::REASON_ORDER_NOT_PAYABLE, 'order' => $freshOrder]; + } + + // Steps 5-7: bind + register + persist. Unguarded, a throw here (e.g. + // prepareInvoice() raising a LocalizedException on some order shape) + // would escape uncaught with zero merchant-visible trace of a real + // charge having come through — wrapped so that can never happen. + try { + // setTransactionId() MUST run before registerCaptureNotification(): + // Transaction\Manager::generateTransactionId() returns the + // already-set transaction id unchanged only when one is already + // set before the capture call runs — otherwise it gets a + // `-capture` suffix and the binding lookup above silently stops + // matching. Load-bearing for D7 — never reorder these two lines. + $freshOrder->getPayment()->setTransactionId($reference); + // Base currency, not getGrandTotal() (display currency) — using + // the display amount silently reopens D8 on any display != base + // store. + $freshOrder->getPayment()->registerCaptureNotification($freshOrder->getBaseTotalDue(), true); + + // Overpayment recording (comment only, no separate save — folded + // into the single save below), hoisted here from Webhook.php so + // all three consumers get it, reusing the same dedupe-marker logic. + if ($this->transactionValidator->isOverpayment($transactionDetails, $freshOrder)) { + $paidAmount = $this->transactionValidator->paidSubunits($transactionDetails); + $expectedSubunits = $this->transactionValidator->expectedSubunits($freshOrder); + $this->appendHistoryComment( + $freshOrder, + $reference, + 'overpaid', + sprintf( + 'Paystack: payment overpaid — paid %d, expected %d, reference %s', + $paidAmount, + $expectedSubunits, + $reference + ) + ); + } + + // Persist — a single save covers both the registration and any + // overpayment comment above. + $this->orderRepository->save($freshOrder); + } catch (\Throwable $exc) { + // Money may already have moved by this point (setTransactionId()/ + // registerCaptureNotification() may have run), so this is never + // safe to invite a second payment for — but it IS safe to keep + // retrying the same webhook event, since a transient DB/invoice + // issue may clear. Allow-listed context only — the raw exception + // message is never reflected anywhere customer-facing. + $this->logger->error('Paystack: settlement registration failed after binding checks passed', [ + 'reference' => $reference, + 'order_increment_id' => $freshOrder->getIncrementId(), + ]); + $this->recordRejection( + $freshOrder, + $reference, + TransactionValidator::REASON_REGISTRATION_FAILED, + $transactionDetails + ); + return ['reason' => TransactionValidator::REASON_REGISTRATION_FAILED, 'order' => $freshOrder]; + } + + return ['reason' => null, 'order' => $freshOrder]; + } + + /** + * The stable (reference, reason) marker embedded in a history comment — + * carried over byte-for-byte from Webhook.php's own historyMarker(), + * whose 72-hour-retry dedupe depends on this exact format string. + * + * @param string $reference + * @param string $reasonKey + * @return string + */ + private function historyMarker(string $reference, string $reasonKey): string + { + return sprintf('[paystack:%s:%s]', $reference, $reasonKey); + } + + /** + * Writes the merchant-visible rejection history comment (with the dedupe + * marker) and logs, for every rejection this class produces, on every + * consumer — not just the webhook, which is where this used to live + * exclusively. + * + * @param Order $order + * @param string $reference + * @param string $reason + * @param object $transactionDetails Full envelope PaystackApiClient::verifyTransaction() returns + * @return void + */ + private function recordRejection(Order $order, string $reference, string $reason, object $transactionDetails): void + { + $paidAmount = $this->transactionValidator->paidSubunits($transactionDetails) ?? 'unknown'; + // Guarded the same way TransactionValidator::allowListedContext() + // guards `reference` — a non-scalar `currency` (array/object) must + // not throw during string conversion inside the sprintf() below, + // which would turn a settlement rejection into an uncaught 503 loop. + $rawCurrency = $transactionDetails->data->currency ?? null; + $paidCurrency = is_scalar($rawCurrency) ? substr((string) $rawCurrency, 0, 100) : 'unknown'; + $expectedSubunits = $this->transactionValidator->expectedSubunits($order); + + $this->logger->warning('Paystack: settlement registration rejected', [ + 'reason' => $reason, + 'reference' => $reference, + 'order_increment_id' => $order->getIncrementId(), + ]); + + // Two wordings, matching Webhook.php's pre-hoist recordHistory(): + // "rejected" for reasons a retry can never fix, "not applied yet + // (retry pending)" for reasons that may still resolve on their own + // (e.g. REASON_MODE_MISMATCH) — collapsing both to "rejected" + // regardless of permanence would misrepresent a still-retrying + // reason as decided. + $isPermanent = $this->transactionValidator->isPermanentForWebhook($reason); + + $this->writeHistory( + $order, + $reference, + $reason, + sprintf( + 'Paystack: payment %s — %s: paid %s %s, expected %s %s, reference %s', + $isPermanent ? 'rejected' : 'not applied (retry pending)', + $reason, + $paidAmount, + $paidCurrency, + $expectedSubunits, + $order->getOrderCurrencyCode(), + $reference + ) + ); + } + + /** + * Appends a merchant-visible order-history comment (no save — the caller + * decides when to persist), but only once per (reference, reason) pair — + * a retry of an already-recorded rejection (or a re-verify hitting the + * same overpayment) must not append a near-identical comment every time. + * A failed lookup of the existing history, or a failed append, must not + * escape this class — log and continue. + * + * @param Order $order + * @param string $reference + * @param string $reasonKey + * @param string $comment + * @return bool True when a new comment was actually appended (not a + * dedupe no-op or a failed write) — tells writeHistory() below + * whether a save is even warranted. + */ + private function appendHistoryComment(Order $order, string $reference, string $reasonKey, string $comment): bool + { + $marker = $this->historyMarker($reference, $reasonKey); + try { + foreach ($order->getStatusHistories() ?? [] as $history) { + $existingComment = $history->getComment() ?? ''; + if (strpos($existingComment, $marker) !== false) { + return false; + } + } + + $order->addStatusToHistory($order->getStatus(), $comment . ' ' . $marker); + return true; + } catch (\Throwable $exc) { + $this->logger->error('Paystack: failed to write order history', ['error' => $exc->getMessage()]); + return false; + } + } + + /** + * Appends the history comment and immediately persists it — used only by + * the rejection path, which returns before reaching register()'s own + * single save. Skips the save entirely when nothing was actually + * appended (a dedupe no-op or a failed write) — the same behavior the + * webhook's original recordHistory() had. A failed save must not turn an + * already-decided rejection into a retry that can never fix it — log and + * continue. + * + * @param Order $order + * @param string $reference + * @param string $reasonKey + * @param string $comment + * @return void + */ + private function writeHistory(Order $order, string $reference, string $reasonKey, string $comment): void + { + if (!$this->appendHistoryComment($order, $reference, $reasonKey, $comment)) { + return; + } + + try { + $this->orderRepository->save($order); + } catch (\Throwable $exc) { + $this->logger->error('Paystack: failed to write order history', ['error' => $exc->getMessage()]); + } + } +} diff --git a/Observer/ObserverAfterPaymentVerify.php b/Observer/ObserverAfterPaymentVerify.php index 22024c2..60e28e5 100644 --- a/Observer/ObserverAfterPaymentVerify.php +++ b/Observer/ObserverAfterPaymentVerify.php @@ -3,7 +3,6 @@ namespace Pstk\Paystack\Observer; use Magento\Framework\Event\ObserverInterface; -use Magento\Sales\Model\Order; class ObserverAfterPaymentVerify implements ObserverInterface { @@ -11,26 +10,49 @@ class ObserverAfterPaymentVerify implements ObserverInterface * @var \Magento\Sales\Model\Order\Email\Sender\OrderSender */ protected $orderSender; - + public function __construct( \Magento\Sales\Model\Order\Email\Sender\OrderSender $orderSender ) { $this->orderSender = $orderSender; } + /** + * Email-only: Model\PaymentSettlement::register() now owns advancing the + * order (registerCaptureNotification() flips it to STATE_PROCESSING as a + * side effect), so this observer must not also try to set state/history — + * doing both was a no-op at best (register() already ran first) and, + * once D8's registration was wired in without this fix, a guaranteed + * "getStatus() == pending" mismatch on every successful payment (the + * order is already Processing by the time this observer runs), silently + * skipping the confirmation email. + * + * Gated on `!$order->getEmailSent()` instead of the old + * `getStatus() == "pending"` check — idempotent against a repeat + * dispatch (webhook + inline race, or a retried callback) the same way + * the old gate was, just against the actual signal this observer cares + * about (has the email already gone out) rather than a status value + * register() may have already advanced past. + * + * Known residual, not race-proof: a genuine concurrent webhook-vs-inline + * race can still have both reads see `getEmailSent() === null` and both + * send — bounded by the same missing reference-keyed lock as the D7 + * binding race, not double-fixed here. + * + * Does NOT save $order: this observer receives the caller's own, + * pre-register() instance, which is stale relative to whatever + * register() already wrote and saved. A save() here would write that + * stale instance back over the row register() just persisted, clobbering + * the capture/state it just registered. + */ public function execute(\Magento\Framework\Event\Observer $observer) { - //Observer execution code... /** @var \Magento\Sales\Model\Order $order **/ $order = $observer->getPaystackOrder(); - - if ($order && $order->getStatus() == "pending") { - // sets the status to processing since payment has been received - $order->setState(Order::STATE_PROCESSING) - ->addStatusToHistory(Order::STATE_PROCESSING, __("Paystack Payment Verified and Order is being processed"), true) - ->setCanSendNewEmailFlag(true) + + if ($order && !$order->getEmailSent()) { + $order->setCanSendNewEmailFlag(true) ->setCustomerNoteNotify(true); - $order->save(); try { $this->orderSender->send($order, true); diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php index a66b5c1..69ec48a 100644 --- a/Test/Unit/Controller/Payment/CallbackTest.php +++ b/Test/Unit/Controller/Payment/CallbackTest.php @@ -8,7 +8,10 @@ use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Exception\ApiException; use Pstk\Paystack\Gateway\Validator\TransactionValidator; +use Pstk\Paystack\Model\PaymentSettlement; use Pstk\Paystack\Model\Ui\ConfigProvider; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; use Magento\Framework\App\Response\RedirectInterface; @@ -19,7 +22,10 @@ use Magento\Framework\Message\ManagerInterface as MessageManager; use Magento\Payment\Helper\Data as PaymentHelper; use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\TransactionSearchResultInterface; use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Api\TransactionRepositoryInterface; +use Magento\Sales\Model\Order; use Magento\Checkout\Model\Session as CheckoutSession; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface; @@ -47,6 +53,12 @@ class CallbackTest extends TestCase /** @var MockObject|LoggerInterface */ private $logger; + /** @var MockObject|OrderRepositoryInterface */ + private $orderRepository; + + /** @var MockObject|TransactionRepositoryInterface */ + private $transactionRepository; + private function createController(): Callback { $this->paystackClient = $this->createMock(PaystackApiClient::class); @@ -55,6 +67,15 @@ private function createController(): Callback $this->orderInterface = $this->createMock(\Magento\Sales\Model\Order::class); $this->messageManager = $this->createMock(MessageManager::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); + // Unconfigured: PaymentSettlement's fresh-refetch falls back to the + // caller's own $order instance (a real Order mock, so `instanceof + // Order` holds) whenever this returns something that isn't one — + // exactly what an unstubbed interface-typed mock does here. + $this->transactionRepository = $this->createMock(TransactionRepositoryInterface::class); + $noExistingBindings = $this->createMock(TransactionSearchResultInterface::class); + $noExistingBindings->method('getItems')->willReturn([]); + $this->transactionRepository->method('getList')->willReturn($noExistingBindings); $redirect = $this->createMock(Redirect::class); $redirect->method('setUrl')->willReturnSelf(); @@ -77,10 +98,22 @@ private function createController(): Callback $this->createMock(\Magento\Framework\Controller\ResultFactory::class) ); + $searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class); + $searchCriteriaBuilder->method('addFilter')->willReturnSelf(); + $searchCriteriaBuilder->method('create')->willReturn($this->createMock(SearchCriteriaInterface::class)); + + $paymentSettlement = new PaymentSettlement( + new TransactionValidator($this->createMock(LoggerInterface::class)), + $this->orderRepository, + $this->transactionRepository, + $searchCriteriaBuilder, + $this->createMock(LoggerInterface::class) + ); + return new Callback( $context, $this->createMock(PageFactory::class), - $this->createMock(OrderRepositoryInterface::class), + $this->orderRepository, $this->orderInterface, $this->createMock(CheckoutSession::class), $this->createMock(PaymentHelper::class), @@ -91,7 +124,8 @@ private function createController(): Callback $this->request, $this->logger, $this->paystackClient, - new TransactionValidator($this->createMock(LoggerInterface::class)) + new TransactionValidator($this->createMock(LoggerInterface::class)), + $paymentSettlement ); } @@ -103,6 +137,11 @@ private function createSettledOrder(string $incrementId): MockObject { $order = $this->createMock(\Magento\Sales\Model\Order::class); $order->method('getIncrementId')->willReturn($incrementId); + $order->method('getEntityId')->willReturn(1); + // PaymentSettlement::register()'s order-state guard: payable by + // default so the settled-order tests reach registration. + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getBaseTotalDue')->willReturn(5000.00); $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); $payment->method('getMethod')->willReturn(\Pstk\Paystack\Model\Payment\Paystack::CODE); @@ -110,6 +149,10 @@ private function createSettledOrder(string $incrementId): MockObject $order->method('getGrandTotal')->willReturn(5000.00); $order->method('getOrderCurrencyCode')->willReturn('NGN'); + // PaymentSettlement's fresh-refetch: this order IS the "fresh" one in + // these tests (no separate stale/fresh distinction needed here). + $this->orderRepository->method('get')->with(1)->willReturn($order); + return $order; } @@ -154,6 +197,67 @@ public function testSuccessfulCallbackDispatchesEvent(): void $controller->execute(); } + /** + * `createSettledOrder()`/`createController()` wire `orderRepository->get()` + * to return the SAME instance loaded via `loadByIncrementId()`, so every + * other test in this file can't tell "the controller dispatches + * PaymentSettlement::register()'s returned order" apart from "the + * controller dispatches its own stale $order variable" — the two are + * always identical there. Here they are deliberately distinct mocks, so a + * regression back to dispatching the stale, pre-registration `$order` + * (missing the `total_paid`/invoice/transaction row registration just + * added) is caught. + */ + public function testDispatchesTheRefetchedOrderRegisterReturnedNotTheStaleLookup(): void + { + $controller = $this->createController(); + + $this->request->method('get') + ->with('reference') + ->willReturn('000000001_suffix'); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('000000001_suffix'), + ]; + $this->paystackClient->method('verifyTransaction') + ->with('000000001_suffix') + ->willReturn($verifyResponse); + + $staleOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $staleOrder->method('getIncrementId')->willReturn('000000001'); + $staleOrder->method('getEntityId')->willReturn(1); + + // createSettledOrder() already wires orderRepository->get(1) to + // return this instance — it is the "fresh" refetch here. + $freshOrder = $this->createSettledOrder('000000001'); + + $this->orderInterface->method('loadByIncrementId') + ->with('000000001') + ->willReturn($staleOrder); + + // A mismatched `->with(...)` argument matcher throws from inside + // dispatch() itself — which this controller's own broad + // `catch (\Throwable $e)` would silently swallow, letting the test + // pass regardless of which order was actually dispatched. Capturing + // the argument instead and asserting on it after execute() returns + // avoids that false-negative. + $dispatchedOrder = null; + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(function (string $eventName, array $data) use (&$dispatchedOrder): void { + $dispatchedOrder = $data['paystack_order'] ?? null; + }); + + $controller->execute(); + + $this->assertSame( + $freshOrder, + $dispatchedOrder, + 'Must dispatch the order PaymentSettlement::register() actually settled and saved, not the stale pre-registration lookup.' + ); + $this->assertNotSame($staleOrder, $dispatchedOrder); + } + /** * The load-bearing half of the gate: which order is settled comes from Paystack's * reply, never from the caller's query string. With both set to the same value a diff --git a/Test/Unit/Controller/Payment/RecreateTest.php b/Test/Unit/Controller/Payment/RecreateTest.php index 3c21b9a..f32e30b 100644 --- a/Test/Unit/Controller/Payment/RecreateTest.php +++ b/Test/Unit/Controller/Payment/RecreateTest.php @@ -7,6 +7,7 @@ use Pstk\Paystack\Controller\Payment\Recreate; use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Validator\TransactionValidator; +use Pstk\Paystack\Model\PaymentSettlement; use Pstk\Paystack\Model\Ui\ConfigProvider; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; @@ -76,7 +77,8 @@ private function createController(): Recreate $request, $this->createMock(LoggerInterface::class), $this->createMock(PaystackApiClient::class), - new TransactionValidator($this->createMock(LoggerInterface::class)) + new TransactionValidator($this->createMock(LoggerInterface::class)), + $this->createMock(PaymentSettlement::class) ); } diff --git a/Test/Unit/Controller/Payment/SetupTest.php b/Test/Unit/Controller/Payment/SetupTest.php index 63a1d3d..16b15c2 100644 --- a/Test/Unit/Controller/Payment/SetupTest.php +++ b/Test/Unit/Controller/Payment/SetupTest.php @@ -9,6 +9,7 @@ use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Pstk\Paystack\Gateway\Exception\ApiException; use Pstk\Paystack\Model\Payment\Paystack; +use Pstk\Paystack\Model\PaymentSettlement; use Pstk\Paystack\Model\Ui\ConfigProvider; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; @@ -109,7 +110,8 @@ private function createController(): Setup $request, $this->createMock(LoggerInterface::class), $this->paystackClient, - $this->transactionValidator + $this->transactionValidator, + $this->createMock(PaymentSettlement::class) ); } diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php index 7d13de2..1b80fbe 100644 --- a/Test/Unit/Controller/Payment/WebhookTest.php +++ b/Test/Unit/Controller/Payment/WebhookTest.php @@ -9,7 +9,10 @@ use Pstk\Paystack\Gateway\Exception\ApiException; use Pstk\Paystack\Gateway\Validator\TransactionValidator; use Pstk\Paystack\Model\Payment\Paystack; +use Pstk\Paystack\Model\PaymentSettlement; use Pstk\Paystack\Model\Ui\ConfigProvider; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\App\Action\Context; use Magento\Framework\App\Request\Http as HttpRequest; use Magento\Framework\Controller\ResultFactory; @@ -18,8 +21,11 @@ use Magento\Framework\View\Result\PageFactory; use Magento\Payment\Helper\Data as PaymentHelper; use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\TransactionSearchResultInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Api\Data\OrderSearchResultInterface; +use Magento\Sales\Api\TransactionRepositoryInterface; +use Magento\Sales\Model\Order; use Magento\Checkout\Model\Session as CheckoutSession; use Magento\Store\Model\StoreManagerInterface; use Psr\Log\LoggerInterface; @@ -53,6 +59,9 @@ class WebhookTest extends TestCase /** @var MockObject|LoggerInterface */ private $logger; + /** @var MockObject|TransactionRepositoryInterface */ + private $transactionRepository; + protected function setUp(): void { $this->paystackClient = $this->createMock(PaystackApiClient::class); @@ -94,6 +103,26 @@ protected function setUp(): void $messageManager = $this->createMock(\Magento\Framework\Message\ManagerInterface::class); $storeManager = $this->createMock(StoreManagerInterface::class); + $this->transactionRepository = $this->createMock(TransactionRepositoryInterface::class); + // Unconfigured: PaymentSettlement's cross-order binding check finds no + // existing transaction for this reference by default — tests that + // need a bound-elsewhere/idempotent case reconfigure this per-test. + $noExistingBindings = $this->createMock(TransactionSearchResultInterface::class); + $noExistingBindings->method('getItems')->willReturn([]); + $this->transactionRepository->method('getList')->willReturn($noExistingBindings); + + $searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class); + $searchCriteriaBuilder->method('addFilter')->willReturnSelf(); + $searchCriteriaBuilder->method('create')->willReturn($this->createMock(SearchCriteriaInterface::class)); + + $paymentSettlement = new PaymentSettlement( + new TransactionValidator($this->createMock(LoggerInterface::class)), + $this->orderRepository, + $this->transactionRepository, + $searchCriteriaBuilder, + $this->createMock(LoggerInterface::class) + ); + $this->controller = new Webhook( $context, $pageFactory, @@ -108,7 +137,8 @@ protected function setUp(): void $this->request, $this->logger, $this->paystackClient, - new TransactionValidator($this->createMock(LoggerInterface::class)) + new TransactionValidator($this->createMock(LoggerInterface::class)), + $paymentSettlement ); } @@ -130,8 +160,13 @@ private function createSettledOrder(string $incrementId): MockObject { $order = $this->createMock(\Magento\Sales\Model\Order::class); $order->method('getId')->willReturn(1); + $order->method('getEntityId')->willReturn(1); $order->method('getIncrementId')->willReturn($incrementId); $order->method('getStatus')->willReturn('pending'); + // PaymentSettlement::register()'s order-state guard: payable by + // default so the settled-order tests reach registration. + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getBaseTotalDue')->willReturn(5000.00); $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); $payment->method('getMethod')->willReturn(Paystack::CODE); @@ -194,6 +229,72 @@ public function testMissingSignatureReturnsAuthFailed(): void $this->controller->execute(); } + /** + * `createSettledOrder()` here — and every other test's use of it — never + * wires `orderRepository->get()` to anything, so `PaymentSettlement`'s + * fresh re-fetch falls back to the SAME `$order` instance `loadByIncrementId()` + * returned: no existing test in this file can tell "dispatches + * `PaymentSettlement::register()`'s returned order" apart from "dispatches + * its own stale `$order` variable". This test wires a distinct fresh + * instance so a regression back to dispatching the pre-registration order + * (missing the `total_paid`/invoice/transaction row registration just + * added) is caught. Also: a mismatched `->with(...)` argument matcher + * would throw from inside `dispatch()`, which this controller's own broad + * `catch (\Throwable $exc)` would silently swallow — so the dispatched + * argument is captured and asserted after `execute()` returns instead. + */ + public function testDispatchesTheRefetchedOrderRegisterReturnedNotTheStaleLookup(): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => [ + 'status' => 'success', + 'reference' => 'ORDER_099', + ], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->with('X-Paystack-Signature')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_099'), + ]; + $this->paystackClient->method('verifyTransaction') + ->with('ORDER_099') + ->willReturn($verifyResponse); + + $this->configProvider->method('getPublicKey')->willReturn('pk_test_123'); + + $staleOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $staleOrder->method('getId')->willReturn(1); + $staleOrder->method('getEntityId')->willReturn(1); + $staleOrder->method('getIncrementId')->willReturn('ORDER_099'); + + $freshOrder = $this->createSettledOrder('ORDER_099'); + $this->orderRepository->method('get')->with(1)->willReturn($freshOrder); + + $this->orderInterface->method('loadByIncrementId') + ->with('ORDER_099') + ->willReturn($staleOrder); + + $dispatchedOrder = null; + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(function (string $eventName, array $data) use (&$dispatchedOrder): void { + $dispatchedOrder = $data['paystack_order'] ?? null; + }); + + $this->controller->execute(); + + $this->assertSame( + $freshOrder, + $dispatchedOrder, + 'Must dispatch the order PaymentSettlement::register() actually settled and saved, not the stale pre-registration lookup.' + ); + $this->assertNotSame($staleOrder, $dispatchedOrder); + } + public function testChargeSuccessWithValidOrder(): void { $rawBody = json_encode([ @@ -228,7 +329,11 @@ public function testChargeSuccessWithValidOrder(): void ->with('ORDER_001') ->willReturn($order); - $this->orderRepository->expects($this->never())->method('save'); + // Registration itself now persists the order (total_paid, invoice, + // transaction row) — the D8 half this reconciliation adds. Previously + // nothing here ever saved because settlementFailureReason() was only + // ever a read. + $this->orderRepository->expects($this->once())->method('save')->with($order); $this->eventManager->expects($this->once()) ->method('dispatch') @@ -1414,6 +1519,71 @@ public static function staleReasonProvider(): array ]; } + /** + * Fix D: REASON_ORDER_NOT_PAYABLE moved from PERMANENT_FOR_WEBHOOK into + * NEVER_RECENCY_BOUNDED — a bank-transfer/USSD charge genuinely `pending` + * at callback time can settle minutes later via this webhook; if the + * customer used `/paystack/payment/recreate` meanwhile, the order is now + * `canceled`, and a late but genuine `charge.success` must keep retrying + * (503) indefinitely, not be permanently dropped (200) — regardless of + * transaction age, unlike MALFORMED above. + * + * @dataProvider orderNotPayableAgeProvider + */ + public function testOrderNotPayableStaysTransientRegardlessOfAge(string $paidAt): void + { + $rawBody = json_encode([ + 'event' => 'charge.success', + 'data' => ['status' => 'success', 'reference' => 'ORDER_047'], + ]); + + $this->request->method('getContent')->willReturn($rawBody); + $this->request->method('getHeader')->willReturn('valid_sig'); + $this->paystackClient->method('validateWebhookSignature')->willReturn(true); + + $verifyResponse = (object) [ + 'data' => (object) $this->settledVerifyData('ORDER_047', ['paid_at' => $paidAt]), + ]; + $this->paystackClient->method('verifyTransaction')->willReturn($verifyResponse); + $this->configProvider->method('getPublicKey')->willReturn('pk_test'); + + // Canceled: not in STATE_NEW/STATE_PENDING_PAYMENT, so + // PaymentSettlement's order-state guard rejects with + // REASON_ORDER_NOT_PAYABLE. + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getId')->willReturn(1); + $order->method('getEntityId')->willReturn(1); + $order->method('getIncrementId')->willReturn('ORDER_047'); + $order->method('getState')->willReturn(Order::STATE_CANCELED); + $order->method('getBaseTotalDue')->willReturn(5000.00); + $payment = $this->createMock(\Magento\Sales\Model\Order\Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + $order->method('getPayment')->willReturn($payment); + $order->method('getGrandTotal')->willReturn(5000.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getStatusHistories')->willReturn([]); + $this->orderInterface->method('loadByIncrementId')->willReturn($order); + + $this->eventManager->expects($this->never())->method('dispatch'); + + $this->rawResult->expects($this->atLeastOnce()) + ->method('setHttpResponseCode') + ->with(503); + $this->rawResult->expects($this->atLeastOnce()) + ->method('setContents') + ->with('unverified'); + + $this->controller->execute(); + } + + public static function orderNotPayableAgeProvider(): array + { + return [ + 'recent' => [date('c', time() - 60)], + 'stale' => [date('c', time() - 7200)], + ]; + } + public function testDuplicateWebhookDeliveryWritesHistoryCommentOnlyOnce(): void { $rawBody = json_encode([ diff --git a/Test/Unit/Gateway/Validator/TransactionValidatorTest.php b/Test/Unit/Gateway/Validator/TransactionValidatorTest.php index 7979e09..9889d00 100644 --- a/Test/Unit/Gateway/Validator/TransactionValidatorTest.php +++ b/Test/Unit/Gateway/Validator/TransactionValidatorTest.php @@ -856,6 +856,9 @@ public static function terminalReasonProvider(): array 'wrong_method' => [TransactionValidator::REASON_WRONG_METHOD], 'malformed' => [TransactionValidator::REASON_MALFORMED], 'mode_mismatch' => [TransactionValidator::REASON_MODE_MISMATCH], + 'reference_bound_elsewhere' => [TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE], + 'order_not_payable' => [TransactionValidator::REASON_ORDER_NOT_PAYABLE], + 'registration_failed' => [TransactionValidator::REASON_REGISTRATION_FAILED], // Regression test for the whole finding: a reason this class does // not (yet) know about must fail closed, not silently invite a // second payment the way the three old, independent policy maps did. @@ -878,6 +881,10 @@ public static function permanentWebhookReasonProvider(): array 'amount_mismatch' => [TransactionValidator::REASON_AMOUNT_MISMATCH], 'currency_mismatch' => [TransactionValidator::REASON_CURRENCY_MISMATCH], 'zero_total' => [TransactionValidator::REASON_ZERO_TOTAL], + // Being bound to a different order is not time-dependent — + // retrying won't change the answer, unlike REASON_ORDER_NOT_PAYABLE + // below. + 'reference_bound_elsewhere' => [TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE], ]; } @@ -900,6 +907,17 @@ public static function transientWebhookReasonProvider(): array // misconfigured secret-key pair is not "the money definitively // didn't settle" the same way a real amount/currency mismatch is. 'mode_mismatch' => [TransactionValidator::REASON_MODE_MISMATCH], + // A bank-transfer/USSD charge genuinely `pending` at callback time + // can settle minutes later via the webhook — if the customer used + // `/paystack/payment/recreate` meanwhile, the order is now + // `canceled`, and a late but genuine `charge.success` must keep + // retrying, not be permanently dropped (money captured with no + // order and no refund path). See Webhook.php's own + // NEVER_RECENCY_BOUNDED for the never-falls-back-to-permanent half. + 'order_not_payable' => [TransactionValidator::REASON_ORDER_NOT_PAYABLE], + // A transient DB/invoice issue during registration should keep + // retrying, since the same event redelivered later may succeed. + 'registration_failed' => [TransactionValidator::REASON_REGISTRATION_FAILED], // Regression test for the whole finding: an unrecognised reason must // default to transient (retry), not permanent — a permanent 200 on a // reason we cannot classify would silently strand a real payment @@ -941,6 +959,9 @@ public static function customerMessageProvider(): array 'wrong_method' => [TransactionValidator::REASON_WRONG_METHOD, $doNotPayAgain], 'malformed' => [TransactionValidator::REASON_MALFORMED, $doNotPayAgain], 'mode_mismatch' => [TransactionValidator::REASON_MODE_MISMATCH, $doNotPayAgain], + 'reference_bound_elsewhere' => [TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, $doNotPayAgain], + 'order_not_payable' => [TransactionValidator::REASON_ORDER_NOT_PAYABLE, $doNotPayAgain], + 'registration_failed' => [TransactionValidator::REASON_REGISTRATION_FAILED, $doNotPayAgain], // Regression test for the whole finding: an unrecognised reason must // fall into the safest copy, exactly like isTerminalForCustomer() // fails closed on terminality for the same input. @@ -1090,6 +1111,9 @@ public function testEveryReasonConstantIsAccountedForInClassificationMaps(): voi TransactionValidator::REASON_CURRENCY_MISMATCH, TransactionValidator::REASON_AMOUNT_MISMATCH, TransactionValidator::REASON_MODE_MISMATCH, + TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, + TransactionValidator::REASON_ORDER_NOT_PAYABLE, + TransactionValidator::REASON_REGISTRATION_FAILED, ]; // isPermanentForWebhook(): explicit -> PERMANENT_FOR_WEBHOOK (true); @@ -1104,6 +1128,9 @@ public function testEveryReasonConstantIsAccountedForInClassificationMaps(): voi TransactionValidator::REASON_WRONG_METHOD, TransactionValidator::REASON_BAD_REFERENCE, TransactionValidator::REASON_MODE_MISMATCH, + TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, + TransactionValidator::REASON_ORDER_NOT_PAYABLE, + TransactionValidator::REASON_REGISTRATION_FAILED, ]; // customerMessage(): explicit -> its own switch case; defaulted -> the @@ -1118,6 +1145,9 @@ public function testEveryReasonConstantIsAccountedForInClassificationMaps(): voi TransactionValidator::REASON_WRONG_METHOD, TransactionValidator::REASON_MALFORMED, TransactionValidator::REASON_MODE_MISMATCH, + TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, + TransactionValidator::REASON_ORDER_NOT_PAYABLE, + TransactionValidator::REASON_REGISTRATION_FAILED, ]; foreach ($reasonConstants as $name => $value) { diff --git a/Test/Unit/Model/PaymentManagementTest.php b/Test/Unit/Model/PaymentManagementTest.php index 6c0bdea..b0434de 100644 --- a/Test/Unit/Model/PaymentManagementTest.php +++ b/Test/Unit/Model/PaymentManagementTest.php @@ -8,9 +8,16 @@ use Pstk\Paystack\Gateway\PaystackApiClient; use Pstk\Paystack\Gateway\Exception\ApiException; use Pstk\Paystack\Gateway\Validator\TransactionValidator; +use Pstk\Paystack\Model\PaymentSettlement; use Pstk\Paystack\Model\Payment\Paystack; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Api\SearchCriteriaInterface; use Magento\Framework\Event\Manager as EventManager; use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\TransactionSearchResultInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Api\TransactionRepositoryInterface; +use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Payment as OrderPayment; use Magento\Checkout\Model\Session as CheckoutSession; use Psr\Log\LoggerInterface; @@ -35,6 +42,12 @@ class PaymentManagementTest extends TestCase /** @var MockObject|LoggerInterface */ private $logger; + /** @var MockObject|OrderRepositoryInterface */ + private $orderRepository; + + /** @var MockObject|TransactionRepositoryInterface */ + private $transactionRepository; + protected function setUp(): void { $this->paystackClient = $this->createMock(PaystackApiClient::class); @@ -43,13 +56,35 @@ protected function setUp(): void $this->checkoutSession = $this->createMock(CheckoutSession::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); + // Unconfigured: PaymentSettlement's fresh-refetch falls back to the + // caller's own $order instance (a real Order mock, so `instanceof + // Order` holds) whenever this returns something that isn't one. + $this->transactionRepository = $this->createMock(TransactionRepositoryInterface::class); + $noExistingBindings = $this->createMock(TransactionSearchResultInterface::class); + $noExistingBindings->method('getItems')->willReturn([]); + $this->transactionRepository->method('getList')->willReturn($noExistingBindings); + + $searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class); + $searchCriteriaBuilder->method('addFilter')->willReturnSelf(); + $searchCriteriaBuilder->method('create')->willReturn($this->createMock(SearchCriteriaInterface::class)); + + $paymentSettlement = new PaymentSettlement( + new TransactionValidator($this->createMock(LoggerInterface::class)), + $this->orderRepository, + $this->transactionRepository, + $searchCriteriaBuilder, + $this->createMock(LoggerInterface::class) + ); + $this->paymentManagement = new PaymentManagement( $this->paystackClient, $this->eventManager, $this->orderInterface, $this->checkoutSession, $this->logger, - new TransactionValidator($this->createMock(LoggerInterface::class)) + new TransactionValidator($this->createMock(LoggerInterface::class)), + $paymentSettlement ); } @@ -78,6 +113,11 @@ private function stubMatchingOrder(string $quoteId, float $grandTotal = 5000.00, $order->method('getGrandTotal')->willReturn($grandTotal); $order->method('getOrderCurrencyCode')->willReturn($currencyCode); $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getEntityId')->willReturn(1); + // PaymentSettlement::register()'s order-state guard: payable by + // default so the settled-order tests reach registration. + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getBaseTotalDue')->willReturn($grandTotal); $this->orderInterface->method('loadByIncrementId') ->with('000000001') @@ -137,6 +177,59 @@ public function testVerifyPaymentSuccessful(): void $this->assertEquals('PSK_abc123', $result['data']['reference']); } + /** + * `stubMatchingOrder()` leaves `orderRepository->get()` unconfigured, so + * `PaymentSettlement`'s fresh re-fetch falls back to the SAME `$order` + * instance every other test here already holds — none of them can tell + * "dispatches `PaymentSettlement::register()`'s returned order" apart + * from "dispatches its own stale `$order` variable". This test wires a + * distinct fresh instance so a regression back to dispatching the + * pre-registration order (missing the `total_paid`/invoice/transaction + * row registration just added) is caught. Also: a mismatched + * `->with(...)` argument matcher would throw from inside `dispatch()`, + * which this class's own broad `catch (\Throwable $e)` would silently + * swallow — so the dispatched argument is captured and asserted after + * `verifyPayment()` returns instead. + */ + public function testDispatchesTheRefetchedOrderRegisterReturnedNotTheStaleLookup(): void + { + $quoteId = '42'; + $reference = 'PSK_abc123_-~-_' . $quoteId; + + $txData = $this->buildTxData('success', $quoteId); + $this->paystackClient->method('verifyTransaction')->willReturn((object) ['data' => $txData]); + + $staleOrder = $this->stubMatchingOrder($quoteId); + + $freshOrder = $this->createMock(\Magento\Sales\Model\Order::class); + $freshOrder->method('getEntityId')->willReturn(1); + $freshOrder->method('getIncrementId')->willReturn('000000001'); + $freshOrder->method('getState')->willReturn(Order::STATE_NEW); + $freshOrder->method('getBaseTotalDue')->willReturn(5000.00); + $freshOrder->method('getGrandTotal')->willReturn(5000.00); + $freshOrder->method('getOrderCurrencyCode')->willReturn('NGN'); + $freshPayment = $this->createMock(OrderPayment::class); + $freshPayment->method('getMethod')->willReturn(Paystack::CODE); + $freshOrder->method('getPayment')->willReturn($freshPayment); + $this->orderRepository->method('get')->with(1)->willReturn($freshOrder); + + $dispatchedOrder = null; + $this->eventManager->expects($this->once()) + ->method('dispatch') + ->willReturnCallback(function (string $eventName, array $data) use (&$dispatchedOrder): void { + $dispatchedOrder = $data['paystack_order'] ?? null; + }); + + $this->paymentManagement->verifyPayment($reference); + + $this->assertSame( + $freshOrder, + $dispatchedOrder, + 'Must dispatch the order PaymentSettlement::register() actually settled and saved, not the stale pre-registration lookup.' + ); + $this->assertNotSame($staleOrder, $dispatchedOrder); + } + public function testVerifyPaymentQuoteIdMismatch(): void { $reference = 'PSK_abc123_-~-_42'; diff --git a/Test/Unit/Model/PaymentSettlementTest.php b/Test/Unit/Model/PaymentSettlementTest.php new file mode 100644 index 0000000..edcdfd4 --- /dev/null +++ b/Test/Unit/Model/PaymentSettlementTest.php @@ -0,0 +1,583 @@ +orderRepository = $this->createMock(OrderRepositoryInterface::class); + $this->transactionRepository = $this->createMock(TransactionRepositoryInterface::class); + $this->searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->searchCriteriaBuilder->method('addFilter')->willReturnSelf(); + $this->searchCriteriaBuilder->method('create')->willReturn( + $this->createMock(SearchCriteriaInterface::class) + ); + + $this->paymentSettlement = new PaymentSettlement( + new TransactionValidator($this->createMock(LoggerInterface::class)), + $this->orderRepository, + $this->transactionRepository, + $this->searchCriteriaBuilder, + $this->logger + ); + } + + /** + * @return MockObject|TransactionSearchResultInterface + */ + private function searchResult(array $items) + { + $result = $this->createMock(TransactionSearchResultInterface::class); + $result->method('getItems')->willReturn($items); + + return $result; + } + + private function transactionItem(int $orderId): MockObject + { + $item = $this->createMock(TransactionInterface::class); + $item->method('getOrderId')->willReturn($orderId); + + return $item; + } + + private function makeVerifyData(array $overrides = []): object + { + return (object) array_merge([ + 'status' => 'success', + 'currency' => 'NGN', + 'amount' => 10000, + 'domain' => 'test', + 'reference' => 'PSK_ref_123', + ], $overrides); + } + + /** + * @return MockObject|Order The order the caller passes to register() — + * `orderRepository->get()` is wired to return a DISTINCT fresh mock + * (see below), so reject/success-path assertions are made against the + * actual instance register() writes/saves, not this stale parameter. + */ + private function makeOrder( + ?Payment $payment, + int $entityId = 1, + string $state = Order::STATE_NEW, + float $baseTotalDue = 100.00 + ): MockObject { + $order = $this->createMock(Order::class); + $order->method('getEntityId')->willReturn($entityId); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getPayment')->willReturn($payment); + $order->method('getState')->willReturn($state); + $order->method('getBaseTotalDue')->willReturn($baseTotalDue); + $order->method('getGrandTotal')->willReturn($baseTotalDue); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + + $freshOrder = $this->createMock(Order::class); + $freshOrder->method('getEntityId')->willReturn($entityId); + $freshOrder->method('getIncrementId')->willReturn('000000001'); + $freshOrder->method('getPayment')->willReturn($payment); + $freshOrder->method('getState')->willReturn($state); + $freshOrder->method('getBaseTotalDue')->willReturn($baseTotalDue); + $freshOrder->method('getGrandTotal')->willReturn($baseTotalDue); + $freshOrder->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->orderRepository->method('get')->with($entityId)->willReturn($freshOrder); + + return $order; + } + + private function makePaystackPayment(): MockObject + { + $payment = $this->createMock(Payment::class); + $payment->method('getMethod')->willReturn(Paystack::CODE); + + return $payment; + } + + private function noExistingBindings(): void + { + $this->transactionRepository->method('getList')->willReturn($this->searchResult([])); + } + + public function testSuccessfulRegistrationBindsAndCaptures(): void + { + $this->noExistingBindings(); + + $order = $this->makeOrder($this->makePaystackPayment(), 1, Order::STATE_NEW, 100.00); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->getPayment()->expects($this->once()) + ->method('setTransactionId') + ->with('PSK_ref_123'); + $freshOrder->getPayment()->expects($this->once()) + ->method('registerCaptureNotification') + ->with(100.00, true); + $this->orderRepository->expects($this->once())->method('save')->with($freshOrder); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertNull($result['reason']); + // The settled instance a caller must dispatch/reference — not the + // stale $order it called register() with. + $this->assertSame($freshOrder, $result['order']); + } + + public function testSettlementFailureIsRejectedAndRecordedAgainstFreshOrder(): void + { + $this->noExistingBindings(); + + $wrongPayment = $this->createMock(Payment::class); + $wrongPayment->method('getMethod')->willReturn('checkmo'); + + $order = $this->makeOrder($wrongPayment); + $freshOrder = $this->orderRepository->get(1); + + // The reject path writes against the fresh instance, not the stale + // caller-supplied $order. + $order->expects($this->never())->method('addStatusToHistory'); + $freshOrder->expects($this->once()) + ->method('addStatusToHistory') + ->with($this->anything(), $this->stringContains('wrong_method')); + $this->orderRepository->expects($this->once())->method('save')->with($freshOrder); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_WRONG_METHOD, $result['reason']); + $this->assertSame($freshOrder, $result['order']); + } + + /** + * REASON_IN_FLIGHT means the charge is still settling, not rejected — no + * merchant-visible history comment on every retry of an event that + * resolves on its own (deny-list behavior carried over from Webhook.php's + * original recordHistory()). + */ + public function testInFlightReasonIsReturnedWithoutRecordingHistory(): void + { + $order = $this->makeOrder($this->makePaystackPayment()); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->expects($this->never())->method('addStatusToHistory'); + $this->orderRepository->expects($this->never())->method('save'); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData(['status' => 'pending'])], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_IN_FLIGHT, $result['reason']); + } + + /** + * The D7 attack: this exact reference is already bound to a different + * order. Money moved, just not attributable to this order. + */ + public function testReferenceBoundToDifferentOrderIsRejected(): void + { + $this->transactionRepository->method('getList')->willReturn( + $this->searchResult([$this->transactionItem(999)]) + ); + + $order = $this->makeOrder($this->makePaystackPayment(), 1); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->getPayment()->expects($this->never())->method('registerCaptureNotification'); + $freshOrder->expects($this->once()) + ->method('addStatusToHistory') + ->with($this->anything(), $this->stringContains('reference_bound_elsewhere')); + $this->orderRepository->expects($this->once())->method('save')->with($freshOrder); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_REFERENCE_BOUND_ELSEWHERE, $result['reason']); + $this->assertSame($freshOrder, $result['order']); + } + + /** + * Same-order idempotent short-circuit: the exact compound key (txn_id + * AND order_id both match this order) — a re-verify (webhook/inline race, + * retried callback) of an already-bound reference is a no-op, not an + * error, and must not repeat registerCaptureNotification()/save(). + */ + public function testIdempotentReVerifyOfSameOrderIsANoOp(): void + { + $this->transactionRepository->method('getList')->willReturn( + $this->searchResult([$this->transactionItem(1)]) + ); + + // State already advanced by the earlier, successful call — + // deliberately NOT STATE_NEW/STATE_PENDING_PAYMENT, to prove the + // idempotency check is reached and short-circuits BEFORE the + // order-state guard could misclassify this as REASON_ORDER_NOT_PAYABLE. + $order = $this->makeOrder($this->makePaystackPayment(), 1, Order::STATE_PROCESSING); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->getPayment()->expects($this->never())->method('setTransactionId'); + $freshOrder->getPayment()->expects($this->never())->method('registerCaptureNotification'); + $freshOrder->expects($this->never())->method('addStatusToHistory'); + $this->orderRepository->expects($this->never())->method('save'); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertNull($result['reason']); + $this->assertSame($freshOrder, $result['order']); + } + + /** + * The compound key is (txn_id === $reference) AND (order_id matches) — + * not a vaguer "does this order already have some capture" key. A + * DIFFERENT, new reference against an already-registered order must not + * be silently treated as settled for a charge that was never applied to + * anything: this reference's own (filtered) lookup finds nothing, so + * registration proceeds for the new reference. + */ + public function testDifferentReferenceOnAlreadyRegisteredOrderIsNotTreatedAsIdempotent(): void + { + // The txn_id-filtered query for THIS reference finds nothing — the + // order's other, already-registered reference is a different txn_id + // and so is invisible to this lookup, exactly as it should be. + $this->noExistingBindings(); + + // grandTotal stays positive so settlementFailureReason()'s own + // ZERO_TOTAL check (driven by getGrandTotal(), not getBaseTotalDue()) + // doesn't fire first — this test is specifically about + // PaymentSettlement's OWN order-state guard, reached only once + // settlementFailureReason() has already passed. + $order = $this->makeOrder($this->makePaystackPayment(), 1, Order::STATE_PROCESSING, 100.00); + $freshOrder = $this->orderRepository->get(1); + // STATE_PROCESSING (not payable) is what this test actually exercises; + // the point is it is NOT the idempotent null short-circuit, proving + // the compound key didn't fire on order id alone for a brand-new + // reference this order has never seen. + $freshOrder->expects($this->once()) + ->method('addStatusToHistory') + ->with($this->anything(), $this->stringContains('order_not_payable')); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData(['reference' => 'PSK_new_ref'])], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_ORDER_NOT_PAYABLE, $result['reason']); + } + + public function testOrderNotInPayableStateIsRejected(): void + { + $this->noExistingBindings(); + + $order = $this->makeOrder($this->makePaystackPayment(), 1, Order::STATE_CANCELED); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->getPayment()->expects($this->never())->method('registerCaptureNotification'); + $freshOrder->expects($this->once()) + ->method('addStatusToHistory') + ->with($this->anything(), $this->stringContains('order_not_payable')); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_ORDER_NOT_PAYABLE, $result['reason']); + } + + public function testZeroBaseTotalDueIsRejectedAsNotPayable(): void + { + $this->noExistingBindings(); + + // grandTotal stays positive so settlementFailureReason()'s own + // ZERO_TOTAL check (driven by getGrandTotal()) doesn't fire first — + // this test is specifically about PaymentSettlement's OWN + // getBaseTotalDue() <= 0 guard, a distinct signal (e.g. a partial + // prior payment) reached only once settlementFailureReason() has + // already passed. Built directly (not via makeOrder()) so the fresh + // instance's getBaseTotalDue() can differ from the caller's $order. + $payment = $this->makePaystackPayment(); + + $order = $this->createMock(Order::class); + $order->method('getEntityId')->willReturn(1); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getPayment')->willReturn($payment); + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getBaseTotalDue')->willReturn(100.00); + $order->method('getGrandTotal')->willReturn(100.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + + $freshOrder = $this->createMock(Order::class); + $freshOrder->method('getEntityId')->willReturn(1); + $freshOrder->method('getIncrementId')->willReturn('000000001'); + $freshOrder->method('getPayment')->willReturn($payment); + $freshOrder->method('getState')->willReturn(Order::STATE_NEW); + $freshOrder->method('getBaseTotalDue')->willReturn(0.0); + $freshOrder->method('getGrandTotal')->willReturn(100.00); + $freshOrder->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->orderRepository->method('get')->with(1)->willReturn($freshOrder); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_ORDER_NOT_PAYABLE, $result['reason']); + } + + /** + * `register()` takes `OrderInterface`, not concrete `Order` — a caller + * that hands it a plain `OrderInterface` (not the concrete Magento + * class) must get a clean REASON_MALFORMED, not a fatal, when the + * fresh-refetch also can't produce a concrete instance. + */ + public function testNonConcreteOrderInterfaceInputReturnsMalformedNotFatal(): void + { + $order = $this->createMock(OrderInterface::class); + $order->method('getEntityId')->willReturn(42); + + // orderRepository->get() also returns a non-concrete stub (the + // contractual OrderRepositoryInterface::get() return type is + // OrderInterface, not Order) — both fetch paths fail to produce a + // concrete instance, so there is nothing safe to validate/register + // against. + $this->orderRepository->method('get')->with(42)->willReturn( + $this->createMock(OrderInterface::class) + ); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_MALFORMED, $result['reason']); + } + + /** + * A `NoSuchEntityException`/`InputException` from the fresh re-fetch is a + * DEFINITE negative answer — the repository is certain this order does + * not exist (or the id is unusable) — not a transient failure, so this + * must NOT fall back to the caller's own, unvalidated-by-the-repository + * $order: doing so would silently register a capture against an instance + * the repository just said doesn't exist. + */ + public function testFreshRefetchNoSuchEntityDoesNotFallBackAndReturnsMalformed(): void + { + $this->noExistingBindings(); + + $order = $this->createMock(Order::class); + $order->method('getEntityId')->willReturn(1); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getPayment')->willReturn($this->makePaystackPayment()); + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getBaseTotalDue')->willReturn(100.00); + $order->method('getGrandTotal')->willReturn(100.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + + $this->orderRepository->method('get')->with(1)->willThrowException( + new \Magento\Framework\Exception\NoSuchEntityException(__('not found')) + ); + + $order->getPayment()->expects($this->never())->method('registerCaptureNotification'); + $this->orderRepository->expects($this->never())->method('save'); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_MALFORMED, $result['reason']); + } + + /** + * A genuinely transient throwable (anything other than + * NoSuchEntityException/InputException) from the fresh re-fetch must not + * escape — this is a public method on a class shipped in a Marketplace + * module. Falls through to the caller's own $order if that is concrete, + * same as an unusable fetch result. + */ + public function testFreshRefetchTransientThrowFallsBackToCallersConcreteOrder(): void + { + $this->noExistingBindings(); + + $order = $this->createMock(Order::class); + $order->method('getEntityId')->willReturn(1); + $order->method('getIncrementId')->willReturn('000000001'); + $order->method('getPayment')->willReturn($this->makePaystackPayment()); + $order->method('getState')->willReturn(Order::STATE_NEW); + $order->method('getBaseTotalDue')->willReturn(100.00); + $order->method('getGrandTotal')->willReturn(100.00); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + + $this->orderRepository->method('get')->with(1)->willThrowException( + new \RuntimeException('deadlock') + ); + + $order->getPayment()->expects($this->once())->method('registerCaptureNotification'); + $this->orderRepository->expects($this->once())->method('save')->with($order); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertNull($result['reason']); + $this->assertSame($order, $result['order']); + } + + /** + * Overpayment is accepted (any excess is fee) and recorded as a single + * merchant-visible history comment, folded into the same save() as the + * registration itself — not a second, separate save. + */ + public function testOverpaymentIsAcceptedAndRecordedInOneSave(): void + { + $this->noExistingBindings(); + + $order = $this->makeOrder($this->makePaystackPayment(), 1, Order::STATE_NEW, 100.00); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->expects($this->once()) + ->method('addStatusToHistory') + ->with($this->anything(), $this->stringContains('overpaid')); + $this->orderRepository->expects($this->once())->method('save')->with($freshOrder); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData(['amount' => 10100])], + $order, + true + ); + + $this->assertNull($result['reason']); + } + + /** + * Fix E: a throw from the bind/register/save steps — after every + * validation and binding check has already passed — must not escape + * uncaught. It must be recorded as merchant-visible history (money may + * already have moved) and reported as a distinct, retry-safe-for-Paystack + * reason, not silently swallowed with zero trace. + */ + public function testRegistrationThrowingIsCaughtRecordedAndReturnsRegistrationFailed(): void + { + $this->noExistingBindings(); + + $payment = $this->makePaystackPayment(); + $payment->method('registerCaptureNotification') + ->willThrowException(new \RuntimeException('invoice creation failed')); + + $order = $this->makeOrder($payment, 1, Order::STATE_NEW, 100.00); + $freshOrder = $this->orderRepository->get(1); + + $freshOrder->expects($this->once()) + ->method('addStatusToHistory') + ->with($this->anything(), $this->stringContains('registration_failed')); + $this->orderRepository->expects($this->once())->method('save')->with($freshOrder); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_REGISTRATION_FAILED, $result['reason']); + $this->assertSame($freshOrder, $result['order']); + } + + /** + * A retry of an already-recorded rejection must not append a duplicate + * comment, and — since nothing new was written — must not save() either. + */ + public function testDuplicateRejectionDoesNotAppendOrSaveAgain(): void + { + $this->noExistingBindings(); + + $wrongPayment = $this->createMock(Payment::class); + $wrongPayment->method('getMethod')->willReturn('checkmo'); + + $order = $this->makeOrder($wrongPayment); + $freshOrder = $this->orderRepository->get(1); + + $existingHistory = $this->createMock(OrderStatusHistoryInterface::class); + $existingHistory->method('getComment')->willReturn( + 'Some other prose. [paystack:PSK_ref_123:wrong_method]' + ); + $freshOrder->method('getStatusHistories')->willReturn([$existingHistory]); + + $freshOrder->expects($this->never())->method('addStatusToHistory'); + $this->orderRepository->expects($this->never())->method('save'); + + $result = $this->paymentSettlement->register( + (object) ['data' => $this->makeVerifyData()], + $order, + true + ); + + $this->assertSame(TransactionValidator::REASON_WRONG_METHOD, $result['reason']); + } +} diff --git a/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php b/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php index 916b1b2..8f0f338 100644 --- a/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php +++ b/Test/Unit/Observer/ObserverAfterPaymentVerifyTest.php @@ -23,23 +23,22 @@ protected function setUp(): void $this->observer = new ObserverAfterPaymentVerify($this->orderSender); } - public function testPendingOrderTransitionsToProcessing(): void + /** + * Model\PaymentSettlement::register() now owns advancing the order + * (registerCaptureNotification() flips it to STATE_PROCESSING as a side + * effect) — this observer is email-only. It must not set state/history + * itself, and it must not save() the order: it receives the caller's own, + * pre-register() instance, and saving it here would write that stale + * instance back over the row register() already persisted. + */ + public function testEmailNotYetSentSendsConfirmationEmail(): void { $order = $this->createMock(Order::class); - $order->method('getStatus')->willReturn('pending'); + $order->method('getEmailSent')->willReturn(null); - $order->expects($this->once()) - ->method('setState') - ->with(Order::STATE_PROCESSING) - ->willReturn($order); - $order->expects($this->once()) - ->method('addStatusToHistory') - ->with( - Order::STATE_PROCESSING, - $this->callback(fn($msg) => str_contains((string)$msg, 'Paystack Payment Verified')), - true - ) - ->willReturn($order); + $order->expects($this->never())->method('setState'); + $order->expects($this->never())->method('addStatusToHistory'); + $order->expects($this->never())->method('save'); $order->expects($this->once()) ->method('setCanSendNewEmailFlag') ->with(true) @@ -48,7 +47,6 @@ public function testPendingOrderTransitionsToProcessing(): void ->method('setCustomerNoteNotify') ->with(true) ->willReturn($order); - $order->expects($this->once())->method('save'); $this->orderSender->expects($this->once()) ->method('send') @@ -60,21 +58,44 @@ public function testPendingOrderTransitionsToProcessing(): void } /** - * NOTE for the verification-gate work: this asserts the *status string* gate - * that the observer currently uses, which is itself a known defect — a merchant - * who assigns a custom default status to state New (e.g. `awaiting_payment`) - * gets orders whose status is not the literal 'pending', so the observer no-ops - * on every verified payment. When that is fixed to gate on state, this test is - * expected to change; that is a planned correction, not a green test being bent. + * The gate is `!$order->getEmailSent()`, not the order's status/state — + * `register()` may already have advanced the order to Processing by the + * time this observer runs, and that must not itself suppress the email. */ - public function testNonPendingOrderIsNotUpdated(): void + public function testAlreadyAdvancedOrderStillSendsEmailWhenNotYetSent(): void { $order = $this->createMock(Order::class); - $order->method('getStatus')->willReturn('processing'); + $order->method('getEmailSent')->willReturn(null); + $order->method('setCanSendNewEmailFlag')->willReturn($order); + $order->method('setCustomerNoteNotify')->willReturn($order); $order->expects($this->never())->method('setState'); $order->expects($this->never())->method('save'); + $this->orderSender->expects($this->once()) + ->method('send') + ->with($order, true); + + $eventObserver = new Observer(['paystack_order' => $order]); + + $this->observer->execute($eventObserver); + } + + /** + * A repeat dispatch (webhook + inline race, or a retried callback) once the + * confirmation email has already gone out must not send a second one. + */ + public function testEmailAlreadySentDoesNotResend(): void + { + $order = $this->createMock(Order::class); + $order->method('getEmailSent')->willReturn(true); + + $order->expects($this->never())->method('setCanSendNewEmailFlag'); + $order->expects($this->never())->method('setCustomerNoteNotify'); + $order->expects($this->never())->method('save'); + + $this->orderSender->expects($this->never())->method('send'); + $eventObserver = new Observer(['paystack_order' => $order]); $this->observer->execute($eventObserver); @@ -83,13 +104,11 @@ public function testNonPendingOrderIsNotUpdated(): void public function testEmailSendingFailureDoesNotAffectOrderStatus(): void { $order = $this->createMock(Order::class); - $order->method('getStatus')->willReturn('pending'); - $order->method('setState')->willReturn($order); - $order->method('addStatusToHistory')->willReturn($order); + $order->method('getEmailSent')->willReturn(null); $order->method('setCanSendNewEmailFlag')->willReturn($order); $order->method('setCustomerNoteNotify')->willReturn($order); - $order->expects($this->once())->method('save'); + $order->expects($this->never())->method('save'); $this->orderSender->method('send') ->willThrowException(new \Exception('SMTP failure')); @@ -103,11 +122,11 @@ public function testEmailSendingFailureDoesNotAffectOrderStatus(): void public function testNullOrderDoesNotCrash(): void { // This case genuinely only guarantees "does not throw": remove the `$order &&` - // guard from the production code and it dereferences null at getStatus(), + // guard from the production code and it dereferences null at getEmailSent(), // failing here on that Error before the never() below could be evaluated. // The never() is therefore a smoke check, not the assertion doing the work. - // The distinguishing negative case — a real order that must NOT advance — is - // testNonPendingOrderIsNotUpdated above. + // The distinguishing negative case — a real order that must NOT resend — is + // testEmailAlreadySentDoesNotResend above. $this->orderSender->expects($this->never())->method('send'); $eventObserver = new Observer(['paystack_order' => null]); diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index c9737a9..e78bddd 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -19,6 +19,18 @@ + + + + Pstk\Paystack\Model\PaymentSettlement\Proxy + + + diff --git a/marketplace/src/reference-manual.md b/marketplace/src/reference-manual.md index 8a40904..f02a5f7 100644 --- a/marketplace/src/reference-manual.md +++ b/marketplace/src/reference-manual.md @@ -93,9 +93,9 @@ A refusal is `{"status": false, "reason": ..., "final": ..., "message": ...}`, w | Event | Observer | Effect | |---|---|---| | `sales_order_place_before` | `Observer\ObserverBeforeSalesOrderPlace` | Suppresses the initial order confirmation email | -| `paystack_payment_verify_after` | `Observer\ObserverAfterPaymentVerify` | Sets the order to Processing and sends the confirmation email | +| `paystack_payment_verify_after` | `Observer\ObserverAfterPaymentVerify` | Sends the confirmation email | -`paystack_payment_verify_after` is a custom event dispatched by this module. It is the single point at which order status advances and the confirmation email is sent, and it is dispatched by all three verification paths — inline REST verification, Redirect callback, and webhook. Observe it if you need to hook your own logic to confirmed payment. It is registered in both `etc/frontend/events.xml` (Redirect callback, webhook) and `etc/webapi_rest/events.xml` (the inline REST verification endpoint, which runs in its own DI area) — both registrations are required for every verification path to reach the observer. +`paystack_payment_verify_after` is a custom event dispatched by this module. `Model\PaymentSettlement::register()` — not this event or its observer — is what binds the Paystack reference, registers the payment, and advances order state (`total_paid`, invoice, transaction row), before the event is even dispatched. By the time the event reaches `ObserverAfterPaymentVerify`, the order has already been saved; the observer is email-only, gated on `!$order->getEmailSent()`, and does not save the order itself. It is dispatched by all three verification paths — inline REST verification, Redirect callback, and webhook — each passing the settled order instance `register()` returned, not its own stale one. Observe it if you need to hook your own logic to confirmed payment. It is registered in both `etc/frontend/events.xml` (Redirect callback, webhook) and `etc/webapi_rest/events.xml` (the inline REST verification endpoint, which runs in its own DI area) — both registrations are required for every verification path to reach the observer. `sales_order_place_before` is likewise registered in both areas: `ObserverBeforeSalesOrderPlace` suppresses the initial placement confirmation email until payment verifies, and the inline REST verification endpoint places its order in the `webapi_rest` area just as Redirect callback and webhook place theirs in `frontend` — without both registrations, the inline flow's placement email would never be suppressed while the `paystack_payment_verify_after` registration above still advanced the order, producing a duplicate confirmation email.