Refuse a callback whose transaction is not successful (D5) - #74
Merged
Conversation
/paystack/payment/callback is an anonymous route. It loaded the order named by the caller's `reference` and dispatched `paystack_payment_verify_after` without ever reading the verify response's own `data.status`, so a single unauthenticated GET with a guessed increment ID advanced any order to Processing with nothing paid. Measured on dev-repro: with this change reverted, an order backed by a real `abandoned` transaction still advances to Processing and the caller lands on the success page; with it in place the order stays new/pending, and a genuinely successful test charge on the same route still advances it. Scope, stated precisely: a transaction that is not successful can no longer advance an order. This is not full closure of the callback surface — nothing here compares the amount or the currency, and nothing binds a reference to one order, so a genuine minimum-amount charge carrying a victim's increment ID still passes. That residual is D6/D7, closed by R2.1's amount/currency window and R2.3's payment registration. Alongside the gate, from its diff review: - `catch (Exception $e)` was unqualified in a namespaced file with no `use`, so it resolved to a non-existent class and caught nothing — every non-ApiException escaped as a 500. It is `\Throwable` now. - Neither catch reflects the exception message to the caller any more. Those messages are built from `curl_error()` and Paystack's raw response, so they leaked internal detail and let an anonymous caller tell "no such reference" apart from "reference exists". - In-flight statuses (`pending`, `ongoing`, `queued` — bank transfer and USSD sit there at callback time) get their own message telling the customer not to pay again, rather than the failed-payment retry wording. - A throwable raised after the dispatch no longer shows the failure page: the observer saves the order before it can throw, so that presented a paid, advanced order as failed and invited a second payment. - Rejections log the reference the caller actually sent, not the value `$reference` has been reassigned to, and pass the exception for its trace. Tests: 112 total green. Of the 14 cases in CallbackTest, 12 fail on the pre-fix code and so regression-test this change — every non-success status shape including a missing `status` field (5), the in-flight ones (3), both throwable paths (3), and the ApiException message no longer being reflected (1). The remaining two are characterization tests, passing either way: that the order is loaded from Paystack's reply rather than the caller's query string, and that a response with no `data` at all is refused. Both pin properties a later refactor could quietly drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jules-paystack
force-pushed
the
fix/callback-verify-status-gate
branch
from
August 17, 2026 18:08
3f0f2e1 to
37a4c3d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #73 (base is
fix/money-path-verification-gate) — review that one first; the diff here is one commit.What was wrong
/paystack/payment/callbackis an anonymous route. It loaded the order named by the caller'sreferenceand dispatchedpaystack_payment_verify_afterwithout ever reading the verify response's owndata.status. One unauthenticated GET with a guessed increment ID advanced any order to Processing with nothing paid.Measured on the
dev-reproharness (Magento 2.4.9 / PHP 8.5 / CSP on), against real Paystack test-mode transactions and the real route with no session and no cookies:The control matters: arm A's first green was for the wrong reason (a bad amount meant no Paystack transaction existed, so it failed on not-found rather than on status), and only the reverted-code comparison caught that.
Scope, stated precisely
A transaction that is not successful can no longer advance an order. This is not full closure of the callback surface: nothing here compares the amount or the currency, and nothing binds a reference to one order, so a genuine minimum-amount charge carrying a victim's increment ID still passes. That residual is the amount/currency window and the payment registration in the next items — so please don't write a release note that says the callback is secured.
Also in the commit, all from its diff review
catch (Exception $e)was unqualified in a namespaced file with nouse, so it resolved to a non-existent class and caught nothing — every non-ApiExceptionescaped as a 500. It is\Throwablenow.curl_error()and Paystack's raw response, so they leaked internal detail and let an anonymous caller tell "no such reference" apart from "reference exists".pending,ongoing,queued— bank transfer and USSD sit there at callback time) get their own message telling the customer not to pay again, instead of the failed-payment retry wording.$referenceis reassigned to) and pass the exception for its trace.Tests
112 green. Of
CallbackTest's 14 cases, 12 fail on the pre-fix code and so regression-test this change: every non-success status shape including a missingstatusfield (5), the in-flight ones (3), both throwable paths (3), and theApiExceptionmessage no longer being reflected (1). The other two pass either way and are characterization tests — that the order is loaded from Paystack's reply rather than the caller's query string, and that a response with nodataat all is refused. Both pin properties a later refactor could quietly drop.One decision for you
There is no release vehicle for this yet: the plan defines 3.0.11 (send-side) and 3.1.0 (accept-side), so as it stands this protects nobody until 3.1.0. Either a 3.0.12 security patch carrying D5 alone, or it rides 3.1.0.
Also worth knowing before the next item: registering
paystack_payment_verify_afteroutsideetc/frontend/(the D12 fix) must not land on its own.PaymentManagement::verifyPayment()has no status check at all, so that registration would turn the inline REST leg into a second unauthenticated advance-an-unpaid-order path. Both reviewers flagged it independently at high severity; the plan is updated.🤖 Generated with Claude Code