Skip to content

Reconcile settlement-gate: full accept-side verification, D7/D8/D12 fixes - #77

Merged
jules-paystack merged 3 commits into
masterfrom
feat/reconcile-settlement-gate
Sep 29, 2026
Merged

jules-paystack merged 3 commits into
masterfrom
feat/reconcile-settlement-gate

Conversation

@jules-paystack

Copy link
Copy Markdown
Collaborator

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-abandoned PaymentVerification class — unopened PR, superseded by Model/PaymentSettlement.php here)

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/.

  • Item A — replaces Gateway/Validator/TransactionValidator.php wholesale 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.
  • Item B — wires the validator into all three consumers as a hard settlement gate; fixes D12 (the event that advances an order was only registered in etc/frontend/events.xml, never webapi_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 in webapi_rest, mirroring frontend exactly.
  • Item C — the core fix: Model/PaymentSettlement.php binds 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). Collapses ObserverAfterPaymentVerify to email-only, since registerCaptureNotification() 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 $order instance rather than the settled one this class actually mutated and saved — any 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.

Test plan

  • Test/Unit/vendor/bin/phpunit -c phpunit.xml --no-coverage — 302 tests, 590 assertions, all green
  • Full plan + critique reconciliation, gate-by-gate, in docs/plans/2026-09-28-reconcile-settlement-gate.md (untracked per this repo's convention for unfixed money-path defect maps)
  • Gate 4 runtime verification against real Paystack test-mode charges: same-currency, display≠base-currency, a reverted-code control (proving the base-currency fix's pass is real), and an idempotent re-verify all pass at the DB level (invoice rows, transaction rows, 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)

  • Reference-keyed locking for D7's residual race window (parent plan's corrected R2.4 spec)
  • D9/R2.8 — webhook multi-order lookup by quote_id (getTotalCount() == 1)
  • D8's refund half (canRefund() still returns false)
  • A second, newly-found race shape: concurrent registration of two different references against the same order

🤖 Generated with Claude Code

jules-paystack and others added 3 commits September 28, 2026 17:34
…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>
@jules-paystack
jules-paystack merged commit 0a47d21 into master Sep 29, 2026
5 checks passed
@jules-paystack
jules-paystack deleted the feat/reconcile-settlement-gate branch September 29, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant