Skip to content

Add native WooPayments runtime to WooCommerce Core#2

Draft
vladolaru wants to merge 606 commits into
trunkfrom
exp/core-native-payments
Draft

Add native WooPayments runtime to WooCommerce Core#2
vladolaru wants to merge 606 commits into
trunkfrom
exp/core-native-payments

Conversation

@vladolaru

@vladolaru vladolaru commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Submission Review Guidelines:

Changes proposed in this Pull Request:

This draft PR brings WooPayments into WooCommerce Core as a first-party native payments runtime while preserving the standalone WooPayments extension as the owner whenever it is active. The branch is intentionally staged and fail-closed: the native runtime defaults off through woocommerce_native_payments_enabled, mandatory cutover defaults off, and the production/default-on decision remains separate from this implementation PR.

The approach is to make Core own the high-level payments orchestration and provider seams, then implement WooPayments as the first native provider behind those seams. That keeps WooPayments-specific transport, account, checkout, webhook, and admin behavior under Internal\Payments\Providers\WooPayments, while shared concepts such as runtime ownership, payment processing, payment outcomes, gateway registration, lifecycle events, idempotency, and provider capability metadata live at the Core payments layer.

Architecturally, the branch is organized around these principles:

  1. Core has one runtime owner at a time. NativePaymentsRuntimeArbiter is the single source of truth for whether Core native payments or the standalone WooPayments plugin owns the runtime. If the plugin is active, native registrations stay inert unless the explicit rollout/cutover path says otherwise.
  2. Native provider behavior goes through Core-level contracts. ProviderContract, CapabilityManifest, PaymentProcessingService, PaymentContext, PaymentOutcome, OrderPaymentStore, and OrderPaymentLifecycleService let the WooPayments provider integrate with checkout, orders, captures, refunds, notes, and lifecycle state without making the gateway itself the architectural center.
  3. WooPayments transport is provider-owned, not globally scattered. WooPaymentsApiClient, request objects, account/session services, transport adapters, and provider gateway adapters encapsulate platform calls and keep legacy extension compatibility filters where they are still part of the public behavior contract.
  4. Cutover is defensive and fail-closed. WooPaymentsCutoverController checks runtime ownership, platform/account readiness, queued operational work, pending provider event disposition, legacy Stripe Billing subscription data, admin-surface readiness, and mandatory rollout flags before allowing native ownership to supersede the plugin. The auto-deactivation path is explicit and guarded rather than a side effect of loading Core.
  5. Money movement is handled as a first-class runtime. The branch adds native handling for payment processing, manual capture, refunds, refund webhooks, dispute webhooks, dispute cache invalidation, account notifications, authorization remediation, failed event storage, webhook reliability logging, and order/payment metadata continuity.
  6. Shopper-facing parity is part of the merge, not a follow-up. Blocks checkout, classic checkout, add-payment-method, saved tokens, express checkout, product/cart express buttons, WooPay sessions, test-mode details, card styling, frontend tracking, and WooPayments-specific frontend assets are wired through Core-owned bundles and only load when the relevant native feature path is active.
  7. Merchant-facing parity is also part of the merge. The native admin surfaces include WooPayments settings under the Core Settings > Payments provider route, Overview, Transactions, transaction details, Disputes, dispute evidence submission, Payouts, Documents/VAT modal, Reports, Card Readers, Capital, fraud settings, payment-method settings, onboarding/connect flows, notices, badges, action shells, and legacy WooPayments route redirection to the native settings routes.
  8. Multi-currency is extracted as its own Core domain. Internal\MultiCurrency owns selected-currency state, rate providers, frontend prices/currencies, REST projections, analytics projections, settings, switchers, compatibility layers, geolocation, order context, tracking, and provider-account resolution. WooPayments supplies a provider-backed rate source through the provider boundary instead of the multi-currency domain reaching into concrete WooPayments internals.
  9. Deprecated WooPayments surfaces are removed when safe. The branch removes deprecated welcome/onboarding/promotion admin residue and retires Stripe Billing event handling rather than porting the legacy Stripe Billing engine into Core. Merchant data is not deleted; legacy data-safety guards block unsafe cutover paths instead of silently ignoring money-moving invoice events.
  10. Performance and bundle behavior are treated as runtime requirements. WooPayments-specific frontend logic and styles are bundled as feature-owned assets instead of globally forcing every merchant to pay for native WooPayments UI. Account data remains guarded and cached defensively, and broad verification included bundle/perf comparisons as large-delta gates rather than pretending local timing is production latency proof.

This is a large draft because it preserves the behavioral surface area WooPayments currently owns: checkout, saved payment methods, webhooks, refunds, disputes, reports, settings, onboarding, Tracks/telemetry, multi-currency, subscriptions renewal/authentication flows, and operational cutover safety. The PR is ready for architecture and staged implementation review, but it deliberately does not claim production default-on readiness. The remaining release decision needs production/release-owner evidence for canary parity, error rates, production platform readiness, live queue/financial data safety, production performance, and the actual rollout/default/mandatory flips.

Known absent subsystems

The WooPayments 10.8 extension inventory is now closed in tools/woopayments-merge/subsystem-disposition.md and enforced by tools/woopayments-merge/subsystem-disposition-gate.sh. The source-pinned gate covers 350 extension PHP files with exactly 350 manifest rows: PORTED=144, SUPERSEDED=177, DROPPED=29, and signed DROPPED rows: 29.

The remaining DROPPED rows are explicit product decisions in the manifest, each with a sign-off field, date, and reason. They cover extension-only plugin deactivation survey behavior, extension onboarding experiments, local inbox notes/promotions replaced by remote/native surfaces, and the legacy Stripe Billing subscriptions engine guarded by cutover preflight checks. The post-commit source-pinned final-evidence packet completed with 31 passed, 0 failed, 6 blocked, and 2 acknowledged manual; the remaining blockers are external catalog, provider-membership, account/capability, or local express-wallet prerequisites described below. Production rollout evidence remains a separate release-owner decision.

Autonomous implementation details

The source-pinned verification harness is now committed under tools/woopayments-merge so reviewers can reproduce its scope, mutation-safety, Playwright, parity, and quality-gate contracts. Agent scratchpad sessions remain ignored local working material; historical snapshots are preserved through archival commits followed by revert commits.

Artifact Archive commit Revert commit Path
Verification harness baseline e01351c941 42ba18994c tools/woopayments-merge
Agent scratchpad session 72bd405362 283b041e9d .agents/scratchpad/sessions/2026-06-15-core-native-payments
Independent certification session db9af09671 f7f6f0cbf3 .agents/scratchpad/sessions/2026-06-21-native-woopayments-certification
Review & remediation session 8feefe2aad ddd79a9b2f .agents/scratchpad/sessions/2026-06-22-woopayments-core-merge-parity

The current harness contains the local no-HITL verification scripts, Playwright browser gates, cutover rehearsals, parity probes, financial/event reconciliation helpers, performance/bundle gates, and runbooks used while building the branch. The session snapshots contain the implementation logs, plans, reviews, browser evidence, and gate-result artifacts that explain how the work evolved and where release-only blockers remain. The independent certification session is a separate read-only verification pass against the reference WooPayments extension oracle. The review and remediation session contains the parity classification of each review finding against WooPayments 10.8, the remediation plan, and its implementation log.

To materialize the historical scratchpad sessions locally while reviewing this PR:

git fetch https://github.com/vladolaru/woocommerce.git exp/core-native-payments:refs/remotes/vladolaru/exp/core-native-payments
git checkout -b review-native-woopayments vladolaru/exp/core-native-payments
git restore --source 72bd405362 -- .agents/scratchpad/sessions/2026-06-15-core-native-payments
git restore --source db9af09671 -- .agents/scratchpad/sessions/2026-06-21-native-woopayments-certification
git restore --source 8feefe2aad -- .agents/scratchpad/sessions/2026-06-22-woopayments-core-merge-parity
git status --ignored --short -- .agents/scratchpad/sessions/2026-06-15-core-native-payments .agents/scratchpad/sessions/2026-06-21-native-woopayments-certification .agents/scratchpad/sessions/2026-06-22-woopayments-core-merge-parity

After restore, all three paths are local working files only. They are intentionally ignored and should not be committed back into review follow-up branches unless a reviewer explicitly wants to re-archive updated evidence. The current verification harness requires no restore because it is present in the branch tree.

Post-merge review & remediation pass

After the branch reached feature-complete, it went through a full multi-agent code review of the entire diff, followed by a targeted remediation pass. The remediation is the most recent 41 commits on the branch (range 5abb021c6e..0cae35157d) and is fully self-contained: every fix is TDD, went through independent spec-compliance and code-quality review, and carries its own changelog entry.

Review → findings. A chunked multi-agent review of the whole diff produced 45 verified findings. Because the review ran without the upstream WooPayments source available, each finding was then re-checked against the shipping WooPayments extension (v10.8.0) as an oracle and classified as: a regression (Core worse than the extension), parity (the same behavior exists in the extension), a backward-compatibility risk, or new-in-core.

What was fixed. All 45 findings are resolved or explicitly dispositioned:

  • Regressions vs. the extension — a successful charge whose post-charge order update throws is now kept reconcilable (payment reference persisted + logged) instead of silently leaving the order pending; webhook-supplied dispute values are escaped in order notes; the preserved list-request filter objects regained send() so extensions hooking wcpay_list_transactions_request/wcpay_get_reporting_balance_summary_request don't fatal; account-setting fields are sanitized per type before persistence; previously-swallowed WooPay session / embedded-account-session / Capital exceptions are now logged.
  • Backward compatibility — the removed WooPayments onboarding-task filters (woocommerce_admin_woopayments_onboarding_task_badge @SInCE 8.2.0, ..._additional_data @SInCE 9.4.0) are re-fired via apply_filters_deprecated() so existing hooks keep working with a deprecation notice; the WooPay session auth filter (wcpay_woopay_is_signed_with_blog_token) is made strengthen-only so it can restrict but never grant access; the GET account-session route is retained and documented as a deliberate mobile/extension compatibility choice.
  • Hardening beyond the extension — blocks-checkout testing instructions are sanitized after the config filter; SetupIntent ownership is verified before attaching a saved card; webhook event claiming is made atomic and concurrent refund-instance resolution is serialized under the order lock; concurrent Stripe customer creation is guarded by a per-user lock.
  • Shared parity bugs — flat dispute-summary filters, hyphenated route-ID acceptance, VAT path encoding, a real jittered retry backoff, guarded timezone parsing, prepared statements for multi-currency analytics SQL, and multi-currency hot-path performance (cached orders-existence check, reduced per-currency option reads, cheaper compatibility backtrace capture, and a per-request cart subscription-type cache).
  • Accessibility & tests — duplicate screen-reader announcements removed, an unannounceable placeholder given a role, a dangling aria-controls made conditional; added nonce-rejection coverage, converted multi-currency tests to the framework set_up/tear_down hooks, replaced placeholder assertions with real ones, awaited user-event interactions, and added data-hook contract guards.
  • One re-decided finding — a proposed nonce on the post-KYC email CTA handler was reverted in favor of a documented accepted-risk, because the handler fires from an email link opened arbitrarily later (a short-lived nonce would break the legitimate flow) and the exposure is limited to a capability-gated, allowlisted-stage telemetry event followed by a safe same-page redirect.

Verification gate (all green on the remediation range): PHPStan reports no errors across all 26 modified source files (no baseline additions); PHPCS is clean across all 64 changed PHP files; the WooPayments PHP suites pass (1538 tests) and the multi-currency PHP suites pass (583 tests); the admin TypeScript ts:check is clean; and the touched admin/settings Jest suites pass (475 tests across 30 suites). A broad payments test filter surfaced 11 failures in unrelated Store-API / gateway-list / order tests; these were confirmed to fail identically on the pre-remediation baseline and are untouched by this work.

The analysis and per-finding disposition were captured in an ignored local scratchpad session (.agents/scratchpad/sessions/2026-06-22-woopayments-core-merge-parity/), consistent with the branch's other local sessions; the reproducible artifact for reviewers is the 41 remediation commits and their changelog entries.

Final implementation verification

The latest completion pass fixed gaps at their owning boundaries rather than weakening Core behavior for local fixtures: request and mapping responsibilities were extracted from the provider adapter; lifecycle enrichment is read-only before lock ownership; currency rates use the Transact V2 route; non-card saved tokens render through their native token classes; provider-name translation is deferred until WordPress initialization; and the harness now enforces exact Docker scope, source pinning, owned mutation cleanup, structured blocker classification, and Playwright evidence freshness.

The final reliability remediation keeps manual acknowledgment at direct exact LPM/token rows and makes unverified A4aq, A5f, SC-04, or converted-currency restoration stop later evidence mutation through cleanup exit 70.

Final committed-state gates:

  • Complete offline evidence-harness regression: 561 passed.
  • WooPayments-only PHP suite: 1,967 passed, 33,348 assertions.
  • Full admin Jest: 239/239 suites, 1,952 passed, 3 skipped, 18 snapshots.
  • Admin TypeScript, branch lint, and PHPStan across 2,281 files: passed.
  • Independent product and harness reliability reviews: APPROVE with zero critical, high, or medium findings.
  • Guarded source-pinned WooPayments 10.8 packet: 31 passed, 0 failed, 6 blocked, 2 acknowledged manual.

The two acknowledged rows are limited to exact LPM account/country/capability/customer-authorization prerequisites plus the direct SEPA token-continuity prerequisite. Derived critical-flow blockers never inherit this exception. The unacknowledged blockers remain explicit: released German catalog publication, deterministic test-mode payout membership, symmetric local express-wallet availability, and critical-flow rows derived from those prerequisites. No blocker is converted into a Core implementation requirement or silently treated as passing evidence.

Screenshots or screen recordings:

This draft includes substantial admin and checkout UI work. Focused browser proof was performed locally against native and reference stores during implementation, but public screenshots are not attached yet because the first review target is the architecture and staged integration shape.

How to test the changes in this Pull Request:

Using the WooCommerce Testing Instructions Guide, include your detailed testing instructions:

  1. Review the runtime ownership boundary.
    • Inspect plugins/woocommerce/src/Internal/Payments/NativePaymentsRuntimeArbiter.php.
    • Confirm DEFAULT_NATIVE_RUNTIME_ENABLED is false.
    • Confirm woocommerce_native_payments_enabled is the explicit rollout filter.
    • Confirm active standalone WooPayments ownership prevents native registrations.
  2. Review the provider architecture.
    • Inspect plugins/woocommerce/src/Internal/Payments/ProviderContract.php, CapabilityManifest.php, NativePaymentsGatewayRegistry.php, PaymentProcessingService.php, OrderPaymentStore.php, PaymentOutcome.php, and PaymentLifecycleEvent.php.
    • Confirm WooPayments-specific behavior lives under plugins/woocommerce/src/Internal/Payments/Providers/WooPayments/ instead of leaking through generic Core payment services.
  3. Review the cutover safety model.
    • Inspect WooPaymentsCutoverController.php.
    • Confirm mandatory cutover defaults false.
    • Confirm preflight checks cover platform/account readiness, queued operational work, provider event disposition, legacy Stripe Billing subscription data, and admin-surface readiness.
    • Confirm failed preflight keeps the standalone plugin/runtime path intact.
  4. Review shopper checkout parity.
    • In a local WooCommerce development store, enable the native runtime with a local test helper that adds add_filter( 'woocommerce_native_payments_enabled', '__return_true' );.
    • Connect/configure a WooPayments test account.
    • Confirm the WooPayments card method appears in blocks checkout and classic checkout.
    • Confirm test-mode checkout displays the test badge and test-card guidance.
    • Confirm express checkout buttons only load when enabled and are styled on product/cart/checkout surfaces.
    • Confirm saved cards can be used in add-payment-method and checkout flows.
  5. Review WooPay behavior.
    • Use a US-based test-mode WooPayments account.
    • Enable WooPay account flags in the local test account state.
    • Confirm WooPay buttons render on the expected shopper surfaces and are not loaded when the merchant/account disables WooPay.
    • Confirm WooPay session creation routes through the native WooPayments session service.
  6. Review admin route integration.
    • Navigate to WooCommerce > Settings > Payments.
    • Confirm WooPayments appears as a provider and provider actions route into the Core Settings > Payments provider route tree.
    • Confirm native WooPayments-specific routes live under the Payments settings parent instead of plugin-owned top-level admin pages.
    • Check Overview, Transactions, Disputes, Payouts, Documents, Reports, Card Readers, Capital, and Settings routes with the native runtime enabled.
  7. Review money movement and webhook behavior.
    • Create a test order paid through native WooPayments.
    • Capture, refund, and partially refund where applicable.
    • Trigger refund, dispute, account-notification, and authorization-related webhook paths in a local provider/test environment.
    • Confirm order notes, metadata, cache invalidation, failed-event logging, and retry/queue behavior are preserved.
  8. Review multi-currency extraction.
    • Inspect plugins/woocommerce/src/Internal/MultiCurrency/.
    • Confirm runtime state, selected currency, rate providers, projections, REST routes, switchers, analytics, and compatibility layers live in the multi-currency domain.
    • Confirm WooPayments supplies provider/account/rate behavior through provider seams instead of direct concrete imports from generic multi-currency services.
  9. Run focused backend tests for the primary native payments runtime:
    • pnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter NativePaymentsRuntimeArbiterTest
    • pnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter WooPaymentsCutoverControllerTest
    • pnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter WooPaymentsProviderGatewayAdapterTest
    • pnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter WooPaymentsEventIngestorTest
    • pnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter NativeWooPaymentsGatewayTest
  10. Run focused frontend tests around the native settings/admin surfaces:
    • From plugins/woocommerce/client/admin, run the relevant Jest tests under client/woopayments/admin/test/.
    • Run pnpm test:js -- settings-page.test.tsx --runInBand from plugins/woocommerce/client/admin.
  11. Run static checks before treating the branch as mergeable:
    • pnpm --filter=@woocommerce/plugin-woocommerce lint:changes:branch
    • git diff --check trunk...HEAD
    • Run focused PHPStan/TypeScript/build checks for any files touched during review follow-ups.

Testing that has already taken place:

This branch was implemented and verified as staged slices rather than one uncontrolled port. The verification performed during the branch included:

  • Focused PHPUnit coverage for the native runtime arbiter, native gateway registry, payment processing service, payment lifecycle/order store, WooPayments provider adapter, WooPayments gateway, checkout bridge, token service, account service, settings service, REST controllers, webhook/event ingestors, refund/dispute/account handlers, operational queue, cutover controller, subscriptions authentication, and multi-currency projections/compatibility layers.
  • Focused Jest coverage for WooPayments settings, provider routes, admin route surfaces, overview cards, money movement tables/details, dispute evidence flows, reports, documents, card readers, capital, settings parity, checkout/admin frontend behavior, and removed deprecated admin surfaces.
  • Browser-driven local checks against a native target store and a reference WooPayments store for checkout, express checkout, WooPay, settings routes, admin surfaces, visual/styling parity, and console/PHP warning scans.
  • Local cutover rehearsals covering plugin-owned default-off behavior, native ownership restore, soft cutover, mandatory auto-deactivation under explicit rollout helpers, activation guard failures, synthetic preflight blockers, platform/account readiness, owner-token readiness, transport continuity, and multisite ownership behavior.
  • Bundle/performance checks used as large-delta gates for the touched surfaces, including WooPayments-specific frontend assets loading through Core build workflows and remaining scoped to enabled native surfaces.
  • Financial and event-path reconciliation checks for refunds, captures, disputes, account notifications, queue readiness, failed webhook logging, and Stripe Billing retirement/data-safety guard behavior.
  • Changelog entries were created manually for the WooCommerce package and the components package where applicable.
  • Fresh pre-push checks: branch is clean, git diff --check origin/trunk...HEAD produced no output, the branch-level lint passes, and the final focused plus aggregate verification results are listed in the final implementation verification section above.

Known release boundary: native rollout/default-on, mandatory cutover, canary parity, production error-rate/performance, production platform readiness, and live data-safety decisions are intentionally not claimed by this draft PR. The implementation remains fail-closed until those release gates are supplied and reviewed.

Milestone

Note: Check the box above to have the milestone automatically assigned when merged.
Alternatively (e.g. for point releases), manually assign the appropriate milestone.

Changelog entry

  • Automatically create a changelog entry from the details below.
  • This Pull Request does not require a changelog entry. (Comment required below)
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Fix - Fixes an existing bug
  • Add - Adds functionality
  • Update - Update existing functionality
  • Dev - Development related task
  • Tweak - A minor adjustment to the codebase
  • Performance - Address performance issues
  • Enhancement - Improvement to existing functionality

Message

Add the native WooPayments runtime, provider surfaces, checkout/admin parity, cutover safeguards, and multi-currency integration in WooCommerce Core.

Changelog Entry Comment

Comment

Created manually for the WooCommerce package and the components package where applicable.

Release Communication

Select if this PR needs a generated summary for release notes:

  • Feature Highlight - For user-facing features (what changed, user impact)
  • Developer Advisory - For developer-facing changes (what changed, how to detect, actions needed)
When to use each?

Feature Highlight: New features, UI changes, or improvements that merchants/store owners will notice.

  • Example: "New bulk editing for products", "Improved checkout performance"

Developer Advisory: Breaking changes, deprecations, or changes that affect themes/plugins/extensions.

  • Example: "Hook signature change", "Deprecated filter", "REST API field removed"

An AI will analyze your PR and post a draft comment for you to review and edit.

vladolaru and others added 26 commits June 22, 2026 09:16
The native WooPayments webhook path could apply the same provider event
twice and coupled the event ingestor to the global container.

WooPaymentsActionSchedulerService::schedule_job() ran a has_pending_action()
SELECT and then as_schedule_single_action() with no $unique flag and no lock
between check and insert, so two concurrent Action Scheduler workers could
both pass the check and both enqueue a job for the same event_id. And
WooPaymentsEventIngestor::process() had no event-ID deduplication, so a
re-delivered event (Action Scheduler retry, provider redelivery, failed-event
replay) re-applied money-affecting side effects: duplicate order-state
transitions, duplicate refund metadata, duplicate dispute updates. Separately,
WooPaymentsEventIngestor::init() constructed its four event handlers with new
and resolved their sub-dependencies inline via wc_get_container()->get(),
coupling it to the global container and making it untestable without a live one.

Pass $unique = true to as_schedule_single_action() so Action Scheduler rejects
duplicates at the database level (the real concurrency guard); keep the
has_pending_action() pre-check. Add a per-event idempotency marker keyed by a
hash of the event ID with an HOUR_IN_SECONDS TTL, checked at the top of
process() and written only after the event is handled successfully, so an event
that throws stays unmarked and can be retried, and events without an ID still
process every time. Inject the four event handlers into init() as required
typed parameters (the RuntimeContainer already auto-wires them) and drop the
new + inline container lookups.

Refs review findings cf82ffd0, 7a098a29

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WooPaymentsWebhookReliabilityService::process_event() deleted the stored
failed-event payload before handing it to the ingestor. WooPaymentsEventIngestor::process()
can throw — on a transient failure the event was already gone from the
failed-event store, so it never reappeared in the server's failed-events list
and the real payment/refund/dispute side effect was permanently lost.

Delete the stored event only after the ingestor settles it: on success because
it is handled, and on InvalidArgumentException because a malformed event can
never succeed and must not loop on every retry. Any other (transient) failure
leaves the event in the store so Action Scheduler retries or the next poll can
re-attempt it. The exception is always re-thrown so the failure still surfaces
to the caller and the retry machinery, matching the method's existing
propagation behavior. Re-attempting after a transient failure is safe because a
thrown event is never marked processed by the ingestor's idempotency guard.

Refs review finding a6ba0a53

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a native WooPayments lifecycle event reached OrderPaymentLifecycleService::apply()
while another in-flight operation (checkout or capture) already held the order payment
lock, the method returned with no log entry. Webhook-triggered events were silently
dropped, leaving the order in a stale state (e.g. "pending" instead of "processing")
until the reliability service recovered it potentially hours later, with zero
diagnostic trail.

Emit a structured WARN-level log on the skip path, including order_id,
payment_reference, event_type, and reason (order_locked) under the conventional
native-payments-webhook source. The lock behavior itself is unchanged; this is
purely additive observability, so apply() keeps its void signature and no callers
are touched.

Refs review finding da67ce24

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native POST wc/v3/payments/webhook route gates on
current_user_can( 'manage_woocommerce' ) and hands the raw payload to the
ingestor with no signature verification. A reviewer flagged this as either a
dead route for real platform traffic (server-to-server deliveries have no WP
session) or an unverified event-injection surface if reachable unauthenticated.

Investigation against the WooPayments plugin (the source this route ports) shows
the gate is intentional, not a native bug:

- WC_REST_Payments_Webhook_Controller registers the same wc/v3/payments/webhook
  route and delegates permission_callback to
  WC_Payments_REST_Controller::check_permission(), which returns
  current_user_can( 'manage_woocommerce' ). The native route is a faithful port.
- Neither the native code nor the upstream WC_Payments_Webhook_Processing_Service
  verifies a webhook signature, and no shared signing secret exists for the
  store. Option B (HMAC verification) is therefore impossible without inventing
  an unverifiable scheme that could break working ingestion.
- The authoritative ingestion path is WooPaymentsWebhookReliabilityService: the
  authenticated pull that fetches failed/missed events via the API client and
  replays them through the ingestor on Action Scheduler jobs. This mirrors
  upstream WC_Payments_Webhook_Reliability_Service exactly.
- grep finds no caller of payments/webhook anywhere in core (no JS, mobile,
  blocks, or PHP) beyond the controller and its own test, so the direct route is
  secondary/vestigial but kept for parity.

Decision: Option A. Keep the route manage_woocommerce-gated (do not loosen,
do not remove) and document why. Loosening the gate would create an unverified
injection surface; there is no secret to verify against. Added doc-blocks on
register_routes() and handle_webhook() recording the auth model and the
authoritative pull path, plus regression tests asserting the permission callback
rejects unauthenticated requests and admits manage_woocommerce admins. The
rejection test was confirmed to fail when the gate is loosened to return true.

Refs review finding 8a819bea

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ThrowingEventIngestor test double added for the webhook event-loss
fix placed an inline comment after the unset() statement, which trips
Squiz.Commenting.PostStatementComment in the branch-level lint. Move the
comment onto its own line.

Refs review finding a6ba0a53

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native operational queue runs background Action Scheduler jobs for
post-KYC activation emails and intent fee-breakdown notes. Two gaps
surfaced in review.

Post-KYC email scheduling gated on a get_option()/update_option() pair.
Two concurrent account-refresh events could both read the scheduled
marker as unset and both schedule every email stage, double-sending
merchant activation emails. Replace the read-then-write gate with an
atomic add_option() insert: it returns false when the marker row already
exists, so only the first concurrent caller schedules and the loser
no-ops. The marker is a dedicated flag, separate from the meaningful
wcpay_kyc_completion_date timestamp read elsewhere, so its semantics are
unchanged.

log_exception() recorded only the message under source => woopayments,
omitting order_id, intent_id, and the job action name. Failed background
jobs could not be correlated with the provider dashboard during
triage. Add an optional context array and forward it alongside source,
then pass the correlation fields each catch block actually has in scope.

Refs review findings 4469e5cb, 866ce759

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native WooPayments settings REST controller had three review findings:

1. should_register_native_settings_routes() failed OPEN: when the runtime
   arbiter was null it registered all native settings routes unconditionally,
   defeating the plugin/native mutual-exclusion guarantee in misconfigured,
   boot-order, or unwired environments. Flip the guard to fail CLOSED so native
   routes register only when an arbiter is wired AND it owns the site.

2. The public file endpoint cached the provider file classification (purpose)
   for DAY_IN_SECONDS. A file reclassified public->private on the provider side
   kept being served to unauthenticated callers for up to 24h. Shorten the cache
   TTL to 5 minutes so reclassifications propagate quickly; the cache-miss path
   already re-fetches the fresh classification and re-checks permissions.

3. The public file endpoint registered an unremovable anonymous closure on
   rest_pre_serve_request on every invocation, stacking duplicate filters across
   dispatches within one request. Replace it with a named method registered at
   most once per request via a has_filter() guard.

Refs review findings ac23644a, 18da887f, a738d900

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… and inject PM promotions service

The native WooPayments onboarding flow wrote `wcpay_onboarding_test_mode`
and `_wcpay_onboarding_stripe_connected` as autoloaded options even
though both are read only by admin-flow services. Autoloaded options are
pulled into the alloptions cache on every front-end request, so these
admin-only markers needlessly bloated that cache.

`WooPaymentsSettingsService::get_pm_promotions_service()` resolved the PM
promotions service inside a `try/catch( Throwable )` that fell back to a
manually constructed instance. That silently hid container
misconfiguration and made the fallback path untestable.

Pass `autoload = false` for the two onboarding-only options. The gateway
settings option (`woocommerce_woocommerce_payments_settings`) stays
autoloaded because the gateway reads it on every front-end request via
WC_Payment_Gateway::init_settings(); a comment records why. Make the PM
promotions service a required, injected `init()` parameter (the DI
container auto-wires it) and return it directly, removing the silent
fallback so missing dependencies surface.

Refs review findings dda2a751, 782dc42b, 59868dc1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Commit 7988daa flipped wcpay_onboarding_test_mode to autoload=false at
the WooPaymentsService write site, but WooPaymentsAccountService writes the
same option at two other call sites without the autoload argument. When the
option does not already exist, update_option delegates to add_option, whose
'auto' default re-autoloads the option — and one of those sites is the
dev-mode branch of is_valid_cached_account(), a front-end-reachable path. So
the original fix was not durable: the option could land autoloaded again.

Pass autoload=false at both WooPaymentsAccountService write sites (the
dev-mode enable path and cleanup_after_account_reset), matching the pattern
established at WooPaymentsService.php:3136, so the option is non-autoloaded
everywhere it is written. Add a test that drives the real dev-mode cache
path and asserts the option is not flagged for autoload by reading the raw
wp_options.autoload column.

This closes the durability gap raised in review of finding dda2a751.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r access

The native WooPayments classic-checkout `update_order_status` AJAX handler
(registered on `wp_ajax[_nopriv]_update_order_status`) validated the nonce and
that the request's `intent_id` matched the order's stored intent metadata, but
performed no order-customer ownership check. On this guest-accessible (`nopriv`)
endpoint, an authenticated user who knew a valid `order_id` plus the matching
`intent_id` could act on and complete another customer's order. The nonce makes
this hard to exploit, but an ownership check is the correct defense-in-depth for
a payment-critical path.

Add a `current_user_can_act_on_order()` guard after the order is loaded and the
intent matched, before any state-affecting work, returning the handler's standard
403 error response on rejection. The check uses core's `pay_for_order` meta-
capability, which grants access when the caller owns the order or the order has
no owner (guest checkout) and otherwise defers to the user's capabilities, so it
mirrors exactly how core guards order-scoped customer actions elsewhere. Guest
orders (customer ID 0) remain accessible via the nopriv flow, and an
authenticated user completing an unowned guest order is unchanged.

Refs review finding 659b20a9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native WooPaymentsCustomerService persists three user options that link
each WordPress user to a Stripe customer — `_wcpay_customer_id`,
`_wcpay_customer_id_live`, and `_wcpay_customer_id_test`. This is a persistent
cross-system personal-data linkage, but no `wp_privacy_personal_data_erasers`
callback cleared it, so a GDPR Article 17 "Erase Personal Data" request
(Tools → Erase Personal Data) left the Stripe linkage intact.

Register an eraser that resolves the user by email and deletes the three
customer-ID user options. Deletion mirrors the writes' default
`$global = false` semantics so the blog-prefixed meta keys actually match.

The eraser is registered unconditionally (not behind the native runtime
arbiter), mirroring the email-unsubscribes Storage and WooPaymentsAccountService
precedents: the options can persist regardless of which payments runtime owns
the site, so erasure coverage must not blink off when ownership changes. The
callback only deletes user options, so it is idempotent and harmless when
there is nothing to remove.

Refs review finding c11a41ac

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MultiCurrencyStateBuilder::build() reconstructed a fresh MultiCurrencyState
on every call. On the front end it runs through the product price filters
(woocommerce_product_get_price and friends), so a shop page with 20 products
times 3 price types fires build() 60+ times per request, each time looping
all enabled currencies and re-reading several options per currency plus
get_woocommerce_currencies(). No memoization existed.

Memoize the assembled state on the builder instance. The front-end price
path reuses a single builder (one MultiCurrencyPriceProjectionService per
request), so the first build() populates the cache and the rest reuse it,
eliminating the redundant option reads and object construction.

The state is request-stable for reads, but two paths legitimately mutate it
mid-request: MultiCurrencyRestController writes enabled/single-currency
options then re-reads build() in the same request, and
MultiCurrencySelectedCurrencyPersistenceService persists a new selected
currency before recalculating the cart. Add a reset() method and call it at
those mutation points so the next build() reflects the change; without it
the REST response would echo the stale enabled set. reset() is added only
because real callers need it, not speculatively.

Refs review finding 13aa6f82

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The memoization in cbabf4b makes build() return one shared
MultiCurrencyState to every caller in a request. Two cheap follow-ups
recommended in the review of finding 13aa6f82 close gaps that change
left latent.

First, the shared state exposes MultiCurrencyCurrency objects with
mutating setters (set_rate, set_charm, set_rounding, set_last_updated).
No caller mutates them today, but one that did would silently corrupt
the cache for every other consumer in the request. Document that
contract on build(), on the MultiCurrencyState class, and on its
get_available_currencies()/get_enabled_currencies() getters so the
read-only expectation is explicit at every entry point.

Second, the existing REST controller and persistence-service tests
inject stub builders that override build(), so the production reset()
call sites were untested: deleting a reset() line broke nothing. Add an
integration-style test that drives update_enabled_currencies() through a
real MultiCurrencyStateBuilder (real localization/rate/cache
collaborators reading the actual enabled-currencies option) and asserts
the response reflects the new enabled set. Verified the test fails when
the reset() line is removed from that call site and passes when
restored, so it now guards the invalidation end-to-end.

Refs review finding 13aa6f82

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native WooPayments gateway fires a few filters/actions under the
standalone plugin's hook names (the wcpay_ prefix and the plugin's
double-prefixed woocommerce_woocommerce_payments_* actions) instead of
the native woocommerce_native_* convention. The names are deliberate:
ecosystem extensions hooked the plugin's names, and reusing them keeps
those extensions working once a site switches to the native runtime.
With no note explaining this, a maintainer could "normalize" the prefix
and silently break extension parity. Add a class-level rationale plus a
per-call-site note at each plugin-named hook, and keep the names as-is.

Separately, the 40+ native payments and multi-currency controllers are
wired unconditionally in class-woocommerce.php; each self-guards at
runtime via its arbiter. There was no documented single emergency
disable at the registration site, risking a slow incident response.
Document the woocommerce_native_payments_enabled filter
(NativePaymentsRuntimeArbiter::FILTER_NATIVE_ENABLED) as the single
kill-switch: returning false forces the native runtime dormant, and
because the multi-currency arbiter delegates to the payments arbiter,
the whole cluster registers nothing.

Documentation only; no behavior change.

Refs review findings 172056bb, 18c8e1c0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Context: The in-progress native WooPayments merge intentionally removed
several public extension surfaces (onboarding-task filters, REST option
allowlist entries, class aliases, a notes facade) and changed the
connect-wcpay endpoint response shape. WooCommerce has a strong
backwards-compatibility culture and the standalone plugin still coexists
with the native runtime during the transition.

Problem: These removals shipped without documentation or an in-code
marker, so an extender hitting a no-longer-fired filter or a maintainer
seeing the 500-to-200 change had no record of intent and might "restore"
the old behavior.

Solution: Verified via git grep that none of the removed surfaces have a
live in-repo consumer (the only remaining references are negative
regression assertions and the unrelated, intentionally-kept
wcpay_welcome_page_connect_nonce admin setting). Added a code comment at
the connect_wcpay native-fallback branch explaining the intentional
500-to-200 redirect change, and a changelog entry documenting all five
removed surfaces with migration notes. No deprecation notice was added:
the onboarding filters have no surviving consult point, and the removed
class symbols have no runtime call site to instrument.

Refs review findings a2dcee8b, 3c43293a, a495a3d7, 12189c8f, 87b63f22

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native WooPayments settings page imported two real React hooks
(useSelectedPaymentMethod, useUnselectedPaymentMethod) under get* aliases,
which hid their use- prefix from ESLint's react-hooks rules and invited a
future Rules-of-Hooks violation if a dev called them conditionally. The
same page also defined a character-for-character identical
onDismissDuplicateNotice callback (plus its duplicated-id and
dismissed-notice state wiring) three times across the payment methods,
BNPL, and express checkout sections.

Import the two hooks under use- prefixed aliases so react-hooks/rules-of-hooks
now lints them, and choose names that avoid the unanchored useSelect
additionalHooks regex used by exhaustive-deps. Collapse the triplicated
dismiss-notice logic into a single local useDuplicatePaymentMethodNotices
hook that the three sections consume. Behavior is unchanged: same store
key, same option write, same dismissed-notice semantics.

Refs review findings cb486709, 21ae23d1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The settings-page test suite carried four test-robustness issues
flagged in the native WooPayments merge code review:

- Two confirmation-modal flows used fireEvent.click, which skips the
  pointer/focus event chain and can pass even if the component gates on
  a focus side-effect. Switched them to await userEvent.click to match
  the suite's prevailing pattern.
- Two tests used fs.readFileSync to read sibling source files and assert
  on webpackChunkName comment strings / static-import absence. These are
  build-time, static-analysis concerns living in a component test:
  fragile to formatting and green even if the lazy load breaks at
  runtime. Removed them; the lazy chunks' on-demand load is already
  proven behaviorally by the async findBy* (Suspense) assertions for the
  VAT modal and fraud tour. Removed the now-unused fs/path imports.
- The module-scoped mockTourKitConfigs array was reset only in
  beforeEach, so a test that threw mid-render could leak stale entries
  into the next test. Added a belt-and-suspenders afterEach reset.
- 25+ assertions scoped by the .woopayments-settings-section CSS class,
  so a CSS rename with no behavior change would break them. Wired
  aria-labelledby on each settings <section> to its heading so it
  exposes a labeled region landmark, then scoped via
  getByRole('region', { name }). Field-group and aria-hidden
  loading-placeholder anchors have no landmark role, so they stay
  class-based with explanatory comments.

Refs review findings 49b39807, 1bc5a045, c7f4cae4, 0491224e

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address code-review notes on the settings-page test-quality change:
- Explain why the disabled-toggle assertion deliberately keeps fireEvent
  (userEvent refuses disabled controls), so it is not later 'fixed' into a
  userEvent call that silently no-ops.
- Correct the afterEach reset comment: beforeEach already guarantees a clean
  slate, so the afterEach is a redundant safety net, not a correctness fix.

Refs review finding 0491224e

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d decode bodies

The native WooPayments API client test suite exercised many distinct API
endpoints inside a handful of omnibus test methods, each reusing one
FakeWooPaymentsHttpClient instance. PHPUnit stops at the first failing
assertion, so a broken endpoint silently prevented every later endpoint in
the same method from being checked, and the test name could not identify
which endpoint actually failed.

Separately, around thirty body assertions checked the raw request-body JSON
string with assertStringContainsString. A substring check cannot tell a
boolean true from the string "true", and cannot distinguish a key at the
body root from the same key nested under evidence or metadata.

Split each omnibus method into one test per endpoint, each backed by a fresh
fake transport via a shared make_sut() helper, so one failing endpoint no
longer masks the rest and the failing test name pinpoints the endpoint. The
lazy string-contains body checks now decode the body and assert the decoded
field, which surfaced a real placement issue: update_dispute nests
customer_name under evidence and order_id under metadata, not at the root.
The deliberate confirm-flag wire-serialization checks (boolean coerced to the
string "true"/"false" for server compatibility) are preserved as raw-string
assertions with an explanatory comment.

Refs review findings e63ddd8a, f7fc3d62

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A prior test-quality change removed two fs.readFileSync tests that asserted
the WooPayments settings optional surfaces stay code-split (reached only via
lazy dynamic imports, never statically imported). That source-introspection
belonged in build tooling, not a Jest component test, so the build-time
intent was deferred.

Replace it with scoped no-restricted-imports ESLint rules: the VAT details
modal, the fraud-protection tour, and the three express-checkout sub-settings
may only be reached through lazy( () => import( ... ) ). no-restricted-imports
flags static import declarations but not dynamic import(), so the lazy imports
keep working while a regression that statically pulls these into the main
settings chunk now fails lint in CI. Route-level chunks under
client/woopayments/admin/ are a separate, lower-risk concern and are not
covered. All 5 lazy sites under settings/ are guarded.

Refs review finding 1bc5a045

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The settings field groups rendered as plain <div>s with no landmark role or
accessible name, so the two remaining test assertions had to scope by walking
up to the .woopayments-settings-field-group CSS class from a heading — brittle
to a class rename, the last residue of the test-anchor cleanup in review
finding 0491224e.

Give FieldGroup role="group" with aria-labelledby wired to its heading (via
useId for a stable id even when no id prop is passed). Field groups are now
labelled group landmarks — a small screen-reader-navigation improvement — and
the two tests scope by getByRole('group', { name }) instead of the CSS class.
The decorative aria-hidden loading placeholder keeps its class-based selector
(it has no landmark role by design).

Refs review finding 0491224e

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native WooPayments subscriptions admin payment-method handler and its
test carried pre-existing branch-level lint debt (flagged by phpcs-changed
against trunk) from earlier work on the branch:
- escape the three thrown InvalidArgumentException messages with esc_html__()
  (WordPress.Security.EscapeOutput.ExceptionNotEscaped); the strings are plain
  text so the rendered output is unchanged.
- add the missing @throws tag to validate_subscription_payment_meta().
- fix equals-sign and array-double-arrow alignment.

No behavior change. Clears the last branch-level lint debt so the pre-push
lint:changes:branch gate passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The native WooPaymentsDisputeEventHandler sprintf'd the raw webhook
dispute status, reason, due-by date, and a non-esc_url'd dispute URL
straight into order-note HTML rendered in WP-Admin. The shipping
WooPayments client escapes these via esc_interpolated_html(), so the
native path was both a stored-XSS shape and a BC/dedup risk: differing
note strings would break order_note_exists() matching on a client to
core migration.

Escape the webhook-supplied scalars (status, reason, due-by) with
esc_html() and all dispute URLs with esc_url() at every note builder
(created, closed, updated). The pre-formatted amount argument comes from
wc_price() HTML and is deliberately left unescaped to avoid
double-escaping the price markup.

Refs review finding 0b11bf8a
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A successful native charge that hit a failure while applying the order
lifecycle outcome (status flip, meta, notes) let the exception propagate
out of process_checkout_outcome(). The order stayed `pending` with no note
and no log even though the provider had already moved money, and a retry
risked re-charging the customer.

Wrap apply_checkout_outcome() so a post-charge failure on a successful
outcome no longer downgrades to FAILED. Instead, persist the provider
payment reference onto the order (only when it carries none, to avoid
clobbering an existing reference) and log the failure at error level with
the order ID and payment reference, then return the successful outcome.
A failure on an unsuccessful outcome still rethrows.

Refs review finding bfcb6ce5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
process_refund() resolved the refund instance ID before claiming the order
lock, so two concurrent refunds of the same amount and reason resolved to
the same fresh refund row. That produced the same per-instance idempotency
key for both: the provider replayed the first refund and the second was
rejected as native_payment_refund_locked, silently dropping a real refund.

Claim the order lock first, keyed on a stable refund-scope token derived
from amount, currency, and reason rather than the per-instance key. Resolve
the refund instance and derive the idempotency key inside the lock. Because
each refund links its row before releasing the lock, the next refund
resolves a distinct unprocessed instance and derives a distinct key, so
concurrent equal refunds serialize instead of colliding. The lock is keyed
by order ID, so unlock_order_payment() releases the scope-key claim
correctly.

Refs review finding 7c43c8da
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etries

Refs review finding 53de2d4b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vladolaru added 30 commits July 13, 2026 11:05
[Context] The payment-details REST controller validates refund requests after converting provider minor units.\n\n[Problem] Its private conversion path embeds another zero-decimal currency catalog.\n\n[Solution] Use WooPaymentsCurrencyUtils directly for refund amount validation and remove both local helpers while retaining the existing request and rounding behavior.
[Context] Order-effect metadata exposes public static helpers for provider amount conversion and zero-decimal classification.\n\n[Problem] Those helpers embed their own catalog and formula, making them a second currency authority.\n\n[Solution] Preserve both callable helpers for compatibility while delegating their behavior to WooPaymentsCurrencyUtils.
[Context] Duplicate-payment protection renders the provider's charged amount when an attached intent does not match the order.\n\n[Problem] That error path embeds its own zero-decimal currency list and conversion formula.\n\n[Solution] Format the charged amount through WooPaymentsCurrencyUtils and remove the service-local converter.
[Context] Seven native WooPayments consumers previously carried independent zero-decimal currency knowledge.\n\n[Problem] A future class-level catalog could silently recreate the drift this consolidation removes.\n\n[Solution] Enumerate every migrated consumer in a reflection regression and require the shared currency utility to remain the sole class-level authority.
[Context] Native payment lifecycle, dispute, refund, and fee notes all need stable replay protection across content and locale changes.\n\n[Problem] Lifecycle and dispute handling maintain separate order-meta markers and note queries alongside the comment-identity mechanism already owned by WooPaymentsOrderNoteService.\n\n[Solution] Route lifecycle and dispute notes through add_note_once with stable identities, preserve legacy-marker reads for cross-version replay, and gate dispute side effects through the shared new-note decision. New writes use comment identity only while existing initialization signatures remain compatible through optional trailing dependencies.
[Context] Provider throwables are normalized into failed outcomes for charge, refund, capture, and cancellation.\n\n[Problem] Normalization was silent, leaving support and reconciliation without an operation trail or the deterministic key needed to correlate a provider attempt.\n\n[Solution] Log one best-effort structured error at each provider catch with operation, order, idempotency key, exception details, and normalized provider code. Keep exception policy pure and allow logger injection through a container-safe optional constructor.
Intent metadata is consumed as part of the WooPayments V1-compatible request contract. It should identify that compatibility surface independently from the WooCommerce Core host version.

The metadata previously used WC_VERSION, which drifted as Core advanced and could claim capabilities the transport does not implement.

Use the request builder's existing WooPayments client capability constant for metadata as well as the mandate user agent, and cover the shared authority with a regression test.
[Context]
WooPayments keeps one canonical settings row and projects canonical-owned fields into split payment-method gateway rows while preserving split-only values.

[Problem]
Other gateway writers can change a split row directly. The next canonical projection already repaired that row, but the competing write remained operationally invisible and ordinary canonical changes could not be distinguished from external drift.

[Solution]
Compare normalized input with the previously stored canonical row, aggregate mismatched existing split rows only for a stable projection, and emit one best-effort structured warning after healing. Keep projection one-way and cover both repair and split-only value preservation.
[Context]
Phase 4 moved lifecycle replay protection from order-meta markers to the shared order-note comment identity.

[Problem]
The event-ingestor integration test still required a newly written legacy marker, contradicting both the new owner-level contract and production behavior and causing the full WooPayments gate to fail.

[Solution]
Assert the payment-success lifecycle identity on the emitted order note and explicitly confirm the retired marker remains absent. This keeps the webhook integration coverage aligned with the unified dedupe authority.
[Context]
MultiCurrencyStateBuilder reset now coordinates request-local invalidation through constructor-initialized state.

[Problem]
Two deterministic anonymous test builders bypass the parent constructor and override build only. Subjects correctly call the inherited reset after settings or selected-currency mutations, causing those fixtures to access an uninitialized typed property and breaking the full MultiCurrency gate.

[Solution]
Give the fixed-state doubles explicit no-op reset implementations. The existing real-builder controller test continues to verify production cache invalidation end to end.
[Context]
Native WooPayments publishes account-aware allowed admin routes and keeps the public cutover readiness filter as an override seam.

[Problem]
The cutover preflight passed a literal true default into that filter, so it could not detect an allowed route that was missing from the native client registry.

[Solution]
Let the admin navigation owner compare every availability key with the canonical registered route inventory, and make that result the existing filter default. Preserve the filter override and inject the owner for isolated cutover tests.
[Context]
Cutover preflight must account for WooPayments background work that is still pending or running in Action Scheduler.

[Problem]
The preflight filtered an empty static hook list, so newly introduced or otherwise unlisted WooPayments actions could remain queued without blocking native ownership.

[Solution]
Discover distinct pending and running Action Scheduler hooks with the WooPayments prefixes in one bounded query, then pass that dynamic list through the existing public override filter. Cover the behavior with a synthetic hook that is intentionally absent from any static inventory.
[Context]
Native cutover normalization relies on the standalone plugin having completed its option-shape migrations.

[Problem]
Preflight did not validate the recorded last-active WooPayments version, so missing, malformed, or older installs could hand incompatible option data to the native runtime.

[Solution]
Require a valid recorded version at or above the 10.5.0 migration boundary and protect that blocker from the generic preflight filter. Keep the minimum version explicit and cover all unsupported version states.
[Context]
A network-active WooPayments extension owns payment runtime on every site in its current WordPress network, so deactivation must be safe for each of them.

[Problem]
The cutover controller evaluated only the dashboard site's preflight before deactivating WooPayments network-wide. Request-local account and legacy-subscription caches also reused the first blog's result after switch_to_blog(), allowing a passing site to hide another site's blocker.

[Solution]
Scan current-network sites in bounded batches, run and memoize preflight per blog, restore blog context reliably, and refuse deactivation when any site fails. Keep account and legacy-subscription caches blog-scoped and report the complete failing site ID list in the support surface and blocked notice.
[Context]\nSupport already exposes native WooPayments ownership and preflight details in the system status report and Site Health debug data.\n\n[Problem]\nSite Health had no runnable check for cutover readiness, so operators could not see ownership and blocking preflight failures in its issue surface.\n\n[Solution]\nRegister an arbiter-independent asynchronous Site Health check with authenticated AJAX and scheduled direct runners. Report the current runtime owner and exact preflight failure codes as a recommended improvement until cutover is ready.
[Context]\nCore and the standalone WooPayments plugin both expose the same five WooCommerce debug-tool IDs during the coexistence window.\n\n[Problem]\nAssociative filter merging allowed whichever runtime ran last to replace the other runtime's labels and callbacks, hiding one set of support actions.\n\n[Solution]\nPrefix only Core's tool IDs with native- while the plugin owns runtime, preserving both sets during coexistence and retaining the established IDs when the plugin is absent.
[Context]\nThe native runtime rollout filter is resolved early and currently requires code-level configuration for emergency control.\n\n[Problem]\nHosts and support cannot disable the native runtime through persisted site configuration, which makes incident response depend on deploying an early filter.\n\n[Solution]\nFeed a true woocommerce_native_payments_killswitch option into the rollout filter's default while preserving the filter as final authority. Document the option and precedence in the shared CLI support note.
[Context]
The native WooPayments release inventory must distinguish full ports from deliberately omitted behavior and preserve workflow ownership across implementation sessions.

[Problem]
The fraud and instant-deposit rows were inaccurate, two known client bundles were absent from the inventory, and file-level coverage did not record both halves of critical merchant and shopper journeys.

[Solution]
Add signed partial-drop semantics and the two exact client-bundle rows, correct the PHP dispositions, and introduce a workflow ledger whose Core owner paths are enforced by the existing gate. The client rows remain deliberately narrower than full client-tree coverage.
[Context]
Cutover preflight compares published admin routes with a canonical client inventory and discovers queued WooPayments actions dynamically.

[Problem]
The canonical routes omitted onboarding, and the queue scan treated actions with existing native consumers as undispositioned. A native-owned store setup sync therefore made every real target fail preflight.

[Solution]
Include the registered onboarding route and subtract exact native-owned Action Scheduler hooks before exposing the pending-disposition filter. Unknown prefixed hooks remain fail-closed, with owner-level regressions covering the complete native consumer set.
[Context]
Phase 5 completed the native WooPayments cutover hardening and verification batch.

[Problem]
The batch did not yet have the package changelog entry required for WooCommerce release notes.

[Solution]
Add a patch-level fix entry covering cutover preflight, multisite rollout controls, support diagnostics, and workflow ownership checks.
[Context]
The native capture context, processing service, provider adapter, and API client already carried optional capture amounts, but no public caller could reach that channel.

[Problem]
The authorization REST route always captured the full order total and could not validate a partial amount against the live provider authorization. Distinct partial-capture idempotency behavior was also not pinned directly.

[Solution]
Accept an optional decimal capture amount, normalize and validate it against the live authorization through the shared minor-unit currency authority, and pass it into the existing context. Preserve omitted full captures and pin amount-sensitive idempotency and transport behavior.
[Context]
Native payment processing providers already publish gateway identity and instances consumed by the gateway registry.

[Problem]
ProviderContract duplicated the gateway-provider methods without extending PaymentGatewayProviderContract, so implementations satisfying the processing contract were not type-compatible with the registry.

[Solution]
Make ProviderContract inherit the gateway-provider contract and remove redundant declarations and implementation markers. Preserve every effective method while expressing the existing subtype relationship directly.
[Context]
Native payments providers exposed persistence keys, outcome mapping, and lifecycle note policy through one profile contract.

[Problem]
New providers had to implement behavior that did not belong to persisted-key vocabulary, while deleting the existing methods would break compatible implementers.

[Solution]
Introduce a narrow persistence vocabulary and an optional provider-owned outcome mapper. Widen generic persistence consumers to the vocabulary while retaining the deprecated profile behavior as a runtime fallback, and delegate WooPayments metadata mapping through its provider port.
[Context]
Native payment providers declare built-in capabilities through a manifest that extensions may also need to enrich.

[Problem]
The manifest silently discarded every unrecognized key, preventing extension-defined capability negotiation and hiding likely declaration mistakes.

[Solution]
Retain provider-defined keys in the normalized manifest while preserving fail-closed reads for undeclared keys. Emit one best-effort warning per distinct unknown key during a request so the open vocabulary remains observable without making logging part of manifest availability.
[Context]
Phase 6 completes the native WooPayments partial-capture channel and clarifies the pre-freeze provider contracts.

[Problem]
The package had no release note covering this additive functionality and contract cleanup.

[Solution]
Add a minor-significance changelog entry for the native authorization amount support and provider extension contract clarification.
Context: Native WooPayments onboarding begins before a merchant account can process payments.

Problem: The adapter gated its injected gateway on processing readiness, creating a circular prerequisite where onboarding needed the gateway to create an account but the gateway was unavailable until that account existed.

Solution: Select the native gateway through the existing onboarding-readiness predicate while leaving payment-processing readiness unchanged. Cover the pre-account state and the genuinely unavailable state with deterministic regressions.
Context: The native onboarding readiness correction changes merchant-visible behavior and requires a WooCommerce changelog entry.

Problem: Without a change fragment, the fix would be absent from the package release notes.

Solution: Record the greenfield native onboarding fix as a backwards-compatible patch.
The LPM browser gate collects response bodies as optional diagnostics after checkout. A response whose body never settled kept the capture set open even after the browser reached the order-received page.

Bound each diagnostic body read to five seconds and retain the existing sanitized failure fallback. This lets successful checkout evidence proceed without weakening order, payment, or failed-response assertions.
Context: The native WooPayments implementation plan requires package changelog coverage for every phase batch.

Problem: Phases 2 through 4 completed their implementation and verification gates without corresponding release metadata.

Solution: Add validated patch entries covering the compatibility, parity, and hygiene work from those phases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant