Repository navigation
Reconcile settlement-gate: full accept-side verification, D7/D8/D12 fixes - #77
Merged
Merged
Conversation
…tem 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 <noreply@anthropic.com>
…Item B) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
3 tasks done
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.
Supersedes two branches, one abandoned and never reviewed:
fix/r2.1-settlement-gate(dated 2026-08-21, never opened as a PR — found by accident mid-session; solved most of the same defect register independently, with a different design)feat/r2-2-r2-3-payment-verification(this session's own earlier, now-abandonedPaymentVerificationclass — unopened PR, superseded byModel/PaymentSettlement.phphere)Summary
Three commits, each independently planned, adversarially reviewed (security-critic + architecture-critic), test-gated, and — for the money-registration piece — verified against real Paystack test-mode charges on
dev-repro/.Gateway/Validator/TransactionValidator.phpwholesale with a reconciled design combining both branches' best ideas: status/amount/currency/method/domain checks, and a two-field (requested_amount/amount) overpayment policy resolved by researching Paystack's actual fee schedule (1.5%+₦100 capped ₦2,000 for local cards; 3.9%+₦100 uncapped for international) rather than picking one branch's policy over the other's.etc/frontend/events.xml, neverwebapi_rest, so inline orders — the module's default integration type — never advanced) and a webhook HMAC empty-secret-key fail-open. A 2-critic review caught a real regression in the first D12 attempt: registering the event observer without its sibling email-suppression observer would have caused a duplicate confirmation email on every inline order — fixed by registering both observers inwebapi_rest, mirroringfrontendexactly.Model/PaymentSettlement.phpbinds a Paystack reference to an order (D7 — closes "one charge settles two orders" to a race window, not fully; reference-keyed locking is a tracked follow-up) and registers the captured payment (D8 —total_paid, invoice, transaction row). CollapsesObserverAfterPaymentVerifyto email-only, sinceregisterCaptureNotification()flips order state as a side effect. A 2-critic review found a high-severity issue in the first draft: all three consumers kept dispatching their own stale, pre-capture$orderinstance rather than the settled one this class actually mutated and saved — any third-party observer on the publicpaystack_payment_verify_afterevent doing an ordinary$order->save()could have silently reverted the capture. Fixed by returning{reason, order}and updating every dispatch site.Test plan
Test/Unit/vendor/bin/phpunit -c phpunit.xml --no-coverage— 302 tests, 590 assertions, all greendocs/plans/2026-09-28-reconcile-settlement-gate.md(untracked per this repo's convention for unfixed money-path defect maps)total_paid/total_due). A 5th arm (D12's inline-specific email path) was inconclusive due to a test-harness limitation, not a code defect — recorded honestly rather than claimed as a pass, with substitute evidence (byte-for-byte event-registration comparison + a real, timestamp-correlated email-send attempt) in the plan file.Known, tracked follow-ups (not silently dropped)
quote_id(getTotalCount() == 1)canRefund()still returnsfalse)🤖 Generated with Claude Code