Add native WooPayments runtime to WooCommerce Core#2
Draft
vladolaru wants to merge 606 commits into
Draft
Conversation
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>
[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.
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.
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:
NativePaymentsRuntimeArbiteris 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.ProviderContract,CapabilityManifest,PaymentProcessingService,PaymentContext,PaymentOutcome,OrderPaymentStore, andOrderPaymentLifecycleServicelet the WooPayments provider integrate with checkout, orders, captures, refunds, notes, and lifecycle state without making the gateway itself the architectural center.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.WooPaymentsCutoverControllerchecks 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.Internal\MultiCurrencyowns 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.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.mdand enforced bytools/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, andsigned DROPPED rows: 29.The remaining
DROPPEDrows 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 with31 passed,0 failed,6 blocked, and2 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-mergeso 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.e01351c94142ba18994ctools/woopayments-merge72bd405362283b041e9d.agents/scratchpad/sessions/2026-06-15-core-native-paymentsdb9af09671f7f6f0cbf3.agents/scratchpad/sessions/2026-06-21-native-woopayments-certification8feefe2aadddd79a9b2f.agents/scratchpad/sessions/2026-06-22-woopayments-core-merge-parityThe 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:
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:
send()so extensions hookingwcpay_list_transactions_request/wcpay_get_reporting_balance_summary_requestdon't fatal; account-setting fields are sanitized per type before persistence; previously-swallowed WooPay session / embedded-account-session / Capital exceptions are now logged.woocommerce_admin_woopayments_onboarding_task_badge@SInCE 8.2.0,..._additional_data@SInCE 9.4.0) are re-fired viaapply_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.aria-controlsmade conditional; added nonce-rejection coverage, converted multi-currency tests to the frameworkset_up/tear_downhooks, replaced placeholder assertions with real ones, awaited user-event interactions, and added data-hook contract guards.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:checkis 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:
561 passed.1,967 passed,33,348 assertions.239/239suites,1,952passed,3skipped,18snapshots.2,281files: passed.APPROVEwith zero critical, high, or medium findings.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:
plugins/woocommerce/src/Internal/Payments/NativePaymentsRuntimeArbiter.php.DEFAULT_NATIVE_RUNTIME_ENABLEDisfalse.woocommerce_native_payments_enabledis the explicit rollout filter.plugins/woocommerce/src/Internal/Payments/ProviderContract.php,CapabilityManifest.php,NativePaymentsGatewayRegistry.php,PaymentProcessingService.php,OrderPaymentStore.php,PaymentOutcome.php, andPaymentLifecycleEvent.php.plugins/woocommerce/src/Internal/Payments/Providers/WooPayments/instead of leaking through generic Core payment services.WooPaymentsCutoverController.php.add_filter( 'woocommerce_native_payments_enabled', '__return_true' );.plugins/woocommerce/src/Internal/MultiCurrency/.pnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter NativePaymentsRuntimeArbiterTestpnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter WooPaymentsCutoverControllerTestpnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter WooPaymentsProviderGatewayAdapterTestpnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter WooPaymentsEventIngestorTestpnpm --filter=@woocommerce/plugin-woocommerce test:php:env -- --filter NativeWooPaymentsGatewayTestplugins/woocommerce/client/admin, run the relevant Jest tests underclient/woopayments/admin/test/.pnpm test:js -- settings-page.test.tsx --runInBandfromplugins/woocommerce/client/admin.pnpm --filter=@woocommerce/plugin-woocommerce lint:changes:branchgit diff --check trunk...HEADTesting 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:
git diff --check origin/trunk...HEADproduced 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
Changelog entry
Changelog Entry Details
Significance
Type
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:
When to use each?
Feature Highlight: New features, UI changes, or improvements that merchants/store owners will notice.
Developer Advisory: Breaking changes, deprecations, or changes that affect themes/plugins/extensions.
An AI will analyze your PR and post a draft comment for you to review and edit.