Commit 5228944384 for woocommerce
commit 5228944384ccbef43f18ff99d8fb25f1c6c31ff5
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Jan 9 17:14:50 2026 +0200
[Payments NOX in LYS] Improve WooPayments plugin state detection (#62100)
* Ensure main context is using a single source of truth for plugin state and is reactive
* Ensure sidebar is using a single source of truth for plugin state and is reactive
* Add changelog
* refac: Use a helper for fetching the Payments task
* Use the provider slug when installing WooPayments
* Fix missing location when removing shell gateways
* Don't log when receiving default cuntry value
* Standardize WooPayments step errors
* Revert to using useSelect on the pluginStore
* Surface backend-provided step errors
* More defensive step error standardization
* Guard against unmounted components
* Avoid rapid re-renders
* Better standardization of step errors
* test: Add unit tests for step error standardization changes
* refac: Use placeholders for brand names in i18n strings
* ai: Teach Claude to handle i18n strings in JS/TS
* Fix markdown linting
* More linting fixes
* Handle nested context in step error data and flatten it
* Handle edge case when a single error is provided for a step
* test: Add unit tests for step error handling of single errors
* fix(payments): add defensive error handling and accessibility to onboarding steps
The WooPayments onboarding error notices lacked defensive coding patterns
that could cause runtime errors when error.message was undefined, and
were missing explicit ARIA attributes for screen reader compatibility.
Add optional chaining and fallback messages to error notice rendering in
wpcom-connection and test-or-live-account steps. Add role="alert" for
better screen reader announcement compatibility with older assistive
technologies. Add try/catch error handling to PaymentsSidebar async task
fetching. Add unit tests for getPaymentsTaskFromLysTasklist function.
Refs WOOPLUG-5903
* fix(payments): add defensive checks and improve accessibility
The setup-payments-context selectors called array methods on potentially
null/undefined returns from getPaymentProviders(), getActivePlugins(), and
getInstalledPlugins(). This could cause runtime errors if the stores
returned unexpected values during loading or error states.
Add Array.isArray() guards to all three selectors and document that
useSelect handles store subscriptions internally (no empty dependency
array needed).
The business-verification step accessed error.message without guarding
against missing message property. Add optional chaining and fallback text.
Also add role="alert" for better screen reader compatibility.
Fix translator comment in PaymentsRemindMeLater to use positional format
(1:) matching the %1$s placeholder in the translatable string.
Refs WOOPLUG-5903
* test(payments): improve WooPayments error standardization test assertions
Test assertions were incomplete - they verified individual keys existed but
didn't ensure expected exceptions were actually thrown or that the error
structure was complete.
Add explicit fail() calls to verify exceptions are thrown, add assertions
for all required error keys (code, message, context), verify context is
not empty when it should contain moved keys, and remove overly strict
count assertion that was brittle.
Refs WOOPLUG-5903
* fix(payments): add missing useSelect dependencies array
TypeScript enforces that useSelect requires a second argument for the
dependencies array. The first useSelect call was missing this argument,
causing build failures.
Add empty dependencies array since the selector subscribes to store state
internally and re-runs automatically when isFetching or providers change.
Refs WOOPLUG-5903
* fix(payments): prevent state flipping during WooPayments slug resolution
When a test/beta WooPayments version is installed under a different slug,
the previous implementation could cause state flipping:
1. Initial load: checks if official slug is active → false
2. After fetch: checks if test slug is active → true
3. UI experiences unexpected false→true transition
Wait for provider data to load before exposing meaningful state. During
loading, render children with stable false values for isWooPaymentsActive
and isWooPaymentsInstalled. This prevents UI from making decisions based
on potentially incorrect state that would flip after load completes.
Refs WOOPLUG-5903
* fix(payments): validate plugin slug before installation
The plugin slug from the provider data was passed directly to the plugin
installer without validation. This could allow unexpected or malicious
input to reach the installation system if the API response was tampered.
Add validatePluginSlug helper that ensures:
- Value is a non-empty string (trimmed)
- Contains only valid characters (lowercase letters, numbers, dashes, underscores)
- Rejects control characters, slashes, spaces, and other special characters
If validation fails, returns undefined which triggers the safe fallback
to the official WooPayments slug constant.
Refs WOOPLUG-5903
* fix(payments): correct interpolation tag mismatch in welcome strings
The Terms of Service links used mismatched interpolation tags `<a1>...</a2>`
which could cause rendering issues with the interpolateComponents function.
Change closing tags from `</a2>` to `</a1>` to properly close the opening
`<a1>` tags in both the standard and WooPay terms strings.
Refs WOOPLUG-5903
* test(payments): add explicit fail assertions for expected exceptions
Tests expecting exceptions should call `$this->fail()` after the code
that should throw, ensuring the test fails if no exception is thrown.
Add `$this->fail('Expected ... not thrown')` calls and improve exception
verification in `onboarding_test_account_init` error handling test.
Refs WOOPLUG-5903
* fix(payments): remove incorrect exception message assertion in test
The catch block in test_error_sanitization_flattens_nested_context was
asserting that the exception message contains 'test_error', but the
WP_Error code ('test_error') is different from its message ('Test error
message').
The catch block's purpose is simply to allow the test to proceed to the
actual error sanitization assertions below - it doesn't need to validate
the exception content.
Refs WOOPLUG-5903
* fix(payments): always show Install step in LYS payments sidebar
The Install/Enable WooPayments step was only shown when the
`wooPaymentsRecentlyActivated` flag was true. This caused the step to
disappear after initial activation, making the sidebar incomplete.
Remove the conditional wrapper so the step always renders with
`isStepComplete={true}` when WooPayments is active, providing consistent
UI feedback regardless of activation timing.
Refs WOOPLUG-5903
* test(payments): add unit tests for PaymentsSidebar Install step visibility
Add comprehensive tests for the InstallWooPaymentsStep component in the
Launch Your Store payments sidebar. Tests verify:
- Install step renders with isStepComplete=false when WooPayments is not active
- Install step renders with isStepComplete=true when WooPayments is active
- Loading placeholder shows when WooPayments is active and loading
- Correct text ("Install" vs "Enable") based on installation state
- Additional onboarding steps render when WooPayments is active
Refs WOOPLUG-5903
* chore: add CLAUDE.local.md to gitignore
Local Claude configuration files should not be tracked in version control
as they contain user-specific settings.
* feat(payments): improve error display in business verification step
The previous implementation only displayed the first error message,
leaving users unaware of additional issues during onboarding setup.
Display up to 3 individual error messages for actionable feedback.
When more errors occur, show a summary count with a generic message
to avoid overwhelming the UI. Add normalizeErrorMessage() to safely
handle empty or whitespace-only messages with a fallback.
Extract OnboardingError interface to types.ts with documentation
explaining the backend contract where both fields are always present
but may be empty strings.
Refs WOOPLUG-5903
* fix(payments): correct test mocks for CI
Fix two test issues that caused CI failures:
- Fix SiteHub mock path in PaymentsSidebar test to match actual import
(~/customize-store/site-hub instead of ~/customize-store/assembler-hub/site-hub)
- Move eslint-disable comment to correct line in BusinessVerificationStep test
to properly suppress the unused variable warning
Refs WOOPLUG-5903
* test(payments): improve PaymentsSidebar test reliability
- Reset mockGetPaymentsTaskFromLysTasklist to default value in beforeEach
to prevent state leakage between tests
- Replace setTimeout-based async handling with waitFor from RTL
- Update "Enable WooPayments" test to actually verify the text content
instead of just checking element presence
Refs WOOPLUG-5903
* test(payments): minor mock and assertion improvements
- Make onClick optional in Button mock to match real component interface
- Use not.toHaveClass() instead of className.not.toContain() for consistency
Refs WOOPLUG-5903
diff --git a/.ai/skills/woocommerce-dev-cycle/SKILL.md b/.ai/skills/woocommerce-dev-cycle/SKILL.md
index 797685191e..e99b4ea7f0 100644
--- a/.ai/skills/woocommerce-dev-cycle/SKILL.md
+++ b/.ai/skills/woocommerce-dev-cycle/SKILL.md
@@ -14,7 +14,8 @@ Follow these guidelines for WooCommerce development workflow:
1. **Running tests**: See [running-tests.md](running-tests.md) for PHP and JavaScript test commands, test environment setup, and troubleshooting
2. **Code quality**: See [code-quality.md](code-quality.md) for linting and code style fixes
3. **PHP linting patterns**: See [php-linting-patterns.md](php-linting-patterns.md) for common PHP linting issues and fixes
-4. **Markdown linting**: See [markdown-linting.md](markdown-linting.md) for markdown file linting and formatting
+4. **JS/TS i18n patterns**: See [js-i18n-patterns.md](js-i18n-patterns.md) for translatable string patterns and placeholder usage
+5. **Markdown linting**: See [markdown-linting.md](markdown-linting.md) for markdown file linting and formatting
## Development Workflow
diff --git a/.ai/skills/woocommerce-dev-cycle/js-i18n-patterns.md b/.ai/skills/woocommerce-dev-cycle/js-i18n-patterns.md
new file mode 100644
index 0000000000..b2ecbbcd0c
--- /dev/null
+++ b/.ai/skills/woocommerce-dev-cycle/js-i18n-patterns.md
@@ -0,0 +1,298 @@
+# JavaScript/TypeScript i18n Patterns
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Translation Functions](#translation-functions)
+- [Placeholder Patterns](#placeholder-patterns)
+- [Translator Comments](#translator-comments)
+- [Complex String Patterns](#complex-string-patterns)
+- [Common Pitfalls](#common-pitfalls)
+- [Quick Command Reference](#quick-command-reference)
+
+## Overview
+
+WooCommerce uses WordPress i18n functions from `@wordpress/i18n` for
+translatable strings. Brand names like "WooPayments" should use placeholders
+to improve translation flexibility.
+
+```typescript
+import { __, sprintf } from '@wordpress/i18n';
+```
+
+## Translation Functions
+
+### Basic Translation
+
+```typescript
+// Simple string
+__( 'Save changes', 'woocommerce' )
+
+// String with placeholder
+sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __( 'Set up %s', 'woocommerce' ),
+ 'WooPayments'
+)
+```
+
+### Plural Forms with `_n`
+
+```typescript
+import { _n, sprintf } from '@wordpress/i18n';
+
+sprintf(
+ /* translators: %d: Number of items */
+ _n(
+ '%d item selected',
+ '%d items selected',
+ count,
+ 'woocommerce'
+ ),
+ count
+)
+```
+
+### Interpolated Elements with `createInterpolateElement`
+
+```typescript
+import { createInterpolateElement } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+
+createInterpolateElement(
+ sprintf(
+ /* translators: 1: Payment provider name */
+ __( 'Enable <strong>%1$s</strong> for your store.', 'woocommerce' ),
+ 'WooPayments'
+ ),
+ {
+ strong: <strong />,
+ }
+)
+```
+
+## Placeholder Patterns
+
+### Single Placeholder
+
+Use `%s` for a single placeholder:
+
+```typescript
+sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __( 'Get paid with %s', 'woocommerce' ),
+ 'WooPayments'
+)
+```
+
+### Multiple Same Placeholders
+
+Use numbered placeholders `%1$s` when the same value appears multiple times:
+
+```typescript
+sprintf(
+ /* translators: 1: Payment provider name (e.g., WooPayments) */
+ __(
+ 'By using %1$s you agree to our Terms. Payments via %1$s are secure.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
+)
+```
+
+### Multiple Different Placeholders
+
+Use numbered placeholders `%1$s`, `%2$s` for different values:
+
+```typescript
+sprintf(
+ /* translators: 1: Payment provider name, 2: Extension names */
+ _n(
+ 'Installing %1$s will activate %2$s extension.',
+ 'Installing %1$s will activate %2$s extensions.',
+ extensionCount,
+ 'woocommerce'
+ ),
+ 'WooPayments',
+ extensionNames
+)
+```
+
+## Translator Comments
+
+### Comment Placement
+
+The translator comment must be placed **immediately before the `__()` or
+`_n()` function**, not before `sprintf()`:
+
+```typescript
+// ❌ WRONG - Comment before sprintf
+/* translators: %s: Payment provider name */
+sprintf(
+ __( 'Set up %s', 'woocommerce' ),
+ 'WooPayments'
+)
+
+// ✅ CORRECT - Comment inside sprintf, before __()
+sprintf(
+ /* translators: %s: Payment provider name */
+ __( 'Set up %s', 'woocommerce' ),
+ 'WooPayments'
+)
+```
+
+### Comment Format for Numbered Placeholders
+
+When using numbered placeholders like `%1$s`, use just the number in the comment:
+
+```typescript
+// ❌ WRONG - Using %1$s in comment
+/* translators: %1$s: Provider name, %2$s: Country */
+
+// ✅ CORRECT - Using just numbers
+/* translators: 1: Provider name, 2: Country */
+```
+
+### Descriptive Comments
+
+Always provide context for translators:
+
+```typescript
+// ❌ WRONG - No context
+/* translators: %s: name */
+
+// ✅ CORRECT - Clear context
+/* translators: %s: Payment provider name (e.g., WooPayments) */
+```
+
+## Complex String Patterns
+
+### Combining `sprintf`, `_n`, and `createInterpolateElement`
+
+```typescript
+installText: ( extensionsString: string ) => {
+ const count = extensionsString.split( ', ' ).length;
+ return createInterpolateElement(
+ sprintf(
+ /* translators: 1: Provider name, 2: Extension names */
+ _n(
+ 'Installing <strong>%1$s</strong> activates <strong>%2$s</strong>.',
+ 'Installing <strong>%1$s</strong> activates <strong>%2$s</strong>.',
+ count,
+ 'woocommerce'
+ ),
+ 'WooPayments',
+ extensionsString
+ ),
+ { strong: <strong /> }
+ );
+}
+```
+
+### Strings with Links
+
+```typescript
+createInterpolateElement(
+ sprintf(
+ /* translators: 1: Payment provider name */
+ __(
+ 'Learn more about <a>%1$s</a> features.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
+ ),
+ {
+ a: (
+ <a
+ href="https://example.com"
+ target="_blank"
+ rel="noopener noreferrer"
+ />
+ ),
+ }
+)
+```
+
+## Common Pitfalls
+
+### Curly Apostrophes
+
+TypeScript files may use curly apostrophes (`'` U+2019) instead of straight
+apostrophes (`'` U+0027). When editing, preserve the original character:
+
+```typescript
+// Original uses curly apostrophe - preserve it
+__( 'I don't want to install another plugin', 'woocommerce' )
+// ^ This is U+2019, not U+0027
+```
+
+### ESLint i18n Rules
+
+The `@wordpress/i18n-translator-comments` rule requires comments directly
+before the translation function:
+
+```typescript
+// ❌ ESLint error - comment not adjacent to __()
+const title = sprintf(
+ /* translators: %s: Provider name */
+
+ __( 'Set up %s', 'woocommerce' ),
+ 'WooPayments'
+);
+
+// ✅ Correct - comment directly before __()
+const title = sprintf(
+ /* translators: %s: Provider name */
+ __( 'Set up %s', 'woocommerce' ),
+ 'WooPayments'
+);
+```
+
+### Brand Names
+
+Always use placeholders for brand names to improve translation flexibility:
+
+```typescript
+// ✅ CORRECT - Use placeholder for brand name
+title: sprintf(
+ /* translators: %s: Payment provider name */
+ __( 'Get paid with %s', 'woocommerce' ),
+ 'WooPayments'
+)
+
+// ✅ CORRECT - Use placeholder in descriptions
+description: sprintf(
+ /* translators: %s: Payment provider name */
+ __( 'Enable PayPal alongside %s', 'woocommerce' ),
+ 'WooPayments'
+)
+
+// ❌ WRONG - Hardcoded brand name
+title: __( 'Get paid with WooPayments', 'woocommerce' )
+```
+
+## Quick Command Reference
+
+```bash
+# Lint specific file
+npx eslint client/path/to/file.tsx
+
+# Fix specific file
+npx eslint --fix client/path/to/file.tsx
+
+# Type check
+pnpm run ts:check
+
+# ❌ NEVER lint entire codebase
+pnpm run lint # NO - lints everything
+```
+
+## Summary
+
+| Pattern | Example |
+|-------------------------------|----------------------------------------------|
+| **Single placeholder** | `sprintf( __( 'Set up %s' ), 'Name' )` |
+| **Repeated placeholder** | `sprintf( __( '%1$s via %1$s' ), 'Name' )` |
+| **Multiple placeholders** | `sprintf( __( '%1$s in %2$s' ), 'A', 'B' )` |
+| **Comment format (simple)** | `/* translators: %s: Provider name */` |
+| **Comment format (numbered)** | `/* translators: 1: Provider, 2: Country */` |
diff --git a/.gitignore b/.gitignore
index aa35e4276a..762e7bd57a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -113,6 +113,7 @@ changes.json
# Claude related files
**/.claude/**/*.local.*
+CLAUDE.local.md
# Cursor related files
.cursorignore
diff --git a/plugins/woocommerce/changelog/update-WOOPLUG-5903-payments-nox-lys-woopayments-plugin-state-detection b/plugins/woocommerce/changelog/update-WOOPLUG-5903-payments-nox-lys-woopayments-plugin-state-detection
new file mode 100644
index 0000000000..44699b103e
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-WOOPLUG-5903-payments-nox-lys-woopayments-plugin-state-detection
@@ -0,0 +1,5 @@
+Significance: patch
+Type: tweak
+Comment: Ensure LYS Payments NOX flows correctly portray the WooPayments' plugin state when calling for action.
+
+
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/data/setup-payments-context.tsx b/plugins/woocommerce/client/admin/client/launch-your-store/data/setup-payments-context.tsx
index 226a721da1..c0105997ac 100644
--- a/plugins/woocommerce/client/admin/client/launch-your-store/data/setup-payments-context.tsx
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/data/setup-payments-context.tsx
@@ -3,7 +3,7 @@
*/
import { createContext, useContext, useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
-import { pluginsStore } from '@woocommerce/data';
+import { pluginsStore, paymentSettingsStore } from '@woocommerce/data';
import { getNewPath } from '@woocommerce/navigation';
/**
@@ -11,6 +11,8 @@ import { getNewPath } from '@woocommerce/navigation';
*/
import { LYSPaymentsSteps } from '~/settings-payments/onboarding/providers/woopayments/steps';
import { OnboardingProvider } from '~/settings-payments/onboarding/providers/woopayments/data/onboarding-context';
+import { isWooPayments } from '~/settings-payments/utils';
+import { wooPaymentsExtensionSlug } from '~/settings-payments/constants';
interface SetUpPaymentsContextType {
isWooPaymentsActive: boolean;
@@ -35,21 +37,61 @@ export const SetUpPaymentsProvider: React.FC< {
children: React.ReactNode;
closeModal: () => void;
} > = ( { children, closeModal } ) => {
- // Check if WooPayments is active by looking for the plugin in the active plugins list
- const isWooPaymentsActive = useSelect(
- ( select ) =>
- select( pluginsStore )
- .getActivePlugins()
- .includes( 'woocommerce-payments' ),
+ // Get the WooPayments provider to access the real plugin slug.
+ // This is important for test/beta versions that may be installed under a different slug.
+ // We wait for the fetch to complete before exposing state to prevent slug instability.
+ const { wooPaymentsPluginSlug, isSlugResolved } = useSelect(
+ ( select ) => {
+ const store = select( paymentSettingsStore );
+ const isFetching = store.isFetching();
+ const providers = store.getPaymentProviders();
+
+ // Defensively check that providers is an array before calling .find().
+ // This prevents runtime errors if getPaymentProviders() returns null/undefined.
+ const wooPaymentsProvider = Array.isArray( providers )
+ ? providers.find( ( provider ) => isWooPayments( provider.id ) )
+ : undefined;
+
+ // Return both the slug and resolution state.
+ // We consider the slug "resolved" when:
+ // 1. We're not fetching anymore, AND
+ // 2. Either we found a provider with a slug, or providers loaded but WooPayments isn't present
+ const hasLoadedProviders =
+ ! isFetching && Array.isArray( providers );
+ const resolvedSlug = wooPaymentsProvider?.plugin?.slug;
+
+ return {
+ wooPaymentsPluginSlug: resolvedSlug ?? wooPaymentsExtensionSlug,
+ isSlugResolved: hasLoadedProviders,
+ };
+ },
+ // Empty deps array - the selector subscribes to store state internally.
+ // It re-runs automatically when store state changes (isFetching, providers).
[]
);
+ // Check if WooPayments is active by looking for the plugin in the active plugins list.
+ const isWooPaymentsActive = useSelect(
+ ( select ) => {
+ const activePlugins = select( pluginsStore ).getActivePlugins();
+ // Defensively check that activePlugins is an array before calling .includes().
+ return Array.isArray( activePlugins )
+ ? activePlugins.includes( wooPaymentsPluginSlug )
+ : false;
+ },
+ [ wooPaymentsPluginSlug ]
+ );
+
const isWooPaymentsInstalled = useSelect(
- ( select ) =>
- select( pluginsStore )
- .getInstalledPlugins()
- .includes( 'woocommerce-payments' ),
- []
+ ( select ) => {
+ const installedPlugins =
+ select( pluginsStore ).getInstalledPlugins();
+ // Defensively check that installedPlugins is an array before calling .includes().
+ return Array.isArray( installedPlugins )
+ ? installedPlugins.includes( wooPaymentsPluginSlug )
+ : false;
+ },
+ [ wooPaymentsPluginSlug ]
);
// State to track if WooPayments was recently enabled
@@ -79,6 +121,26 @@ export const SetUpPaymentsProvider: React.FC< {
preserveParams: [ 'sidebar', 'content' ],
};
+ // Wait for slug resolution to prevent state flipping.
+ // During initial load, we don't know if a test/beta version is installed under a different slug.
+ // Rendering children without waiting could show incorrect UI that then flips after load.
+ if ( ! isSlugResolved ) {
+ // Render children without context-dependent decisions during loading.
+ // This prevents the UI from making incorrect assumptions about WooPayments state.
+ return (
+ <SetUpPaymentsContext.Provider
+ value={ {
+ isWooPaymentsActive: false,
+ isWooPaymentsInstalled: false,
+ wooPaymentsRecentlyActivated: false,
+ setWooPaymentsRecentlyActivated: () => undefined,
+ } }
+ >
+ { children }
+ </SetUpPaymentsContext.Provider>
+ );
+ }
+
return (
<SetUpPaymentsContext.Provider
value={ {
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/hub/main-content/pages/payments-content.tsx b/plugins/woocommerce/client/admin/client/launch-your-store/hub/main-content/pages/payments-content.tsx
index 2c3c7b7ce1..c9c15d3a50 100644
--- a/plugins/woocommerce/client/admin/client/launch-your-store/hub/main-content/pages/payments-content.tsx
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/hub/main-content/pages/payments-content.tsx
@@ -5,7 +5,7 @@ import { useCallback } from 'react';
import apiFetch from '@wordpress/api-fetch';
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import React, { useEffect, useState } from '@wordpress/element';
+import React, { useEffect, useRef, useState } from '@wordpress/element';
import { pluginsStore, paymentSettingsStore } from '@woocommerce/data';
import { useDispatch, useSelect } from '@wordpress/data';
import { WooPaymentsMethodsLogos } from '@woocommerce/onboarding';
@@ -31,12 +31,47 @@ import {
wooPaymentsOnboardingSessionEntryLYS,
} from '~/settings-payments/constants';
+/**
+ * Validates and sanitizes a plugin slug.
+ * Plugin slugs should only contain lowercase letters, numbers, dashes, and underscores.
+ * This prevents passing unexpected or malicious input to plugin installation.
+ *
+ * @param slug - The plugin slug to validate.
+ * @return The validated slug if valid, or undefined if invalid.
+ */
+const validatePluginSlug = ( slug: unknown ): string | undefined => {
+ // Must be a string.
+ if ( typeof slug !== 'string' ) {
+ return undefined;
+ }
+
+ const trimmed = slug.trim();
+
+ // Must be non-empty after trimming.
+ if ( trimmed.length === 0 ) {
+ return undefined;
+ }
+
+ // Plugin slugs should only contain lowercase letters, numbers, dashes, and underscores.
+ // This pattern rejects control characters, slashes, spaces, and other special characters.
+ const validSlugPattern = /^[a-z0-9_-]+$/;
+ if ( ! validSlugPattern.test( trimmed ) ) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[WooCommerce Payments] Invalid plugin slug format: "${ trimmed }". Using default slug.`
+ );
+ return undefined;
+ }
+
+ return trimmed;
+};
+
const InstallWooPaymentsStep = ( {
installWooPayments,
isPluginInstalling,
isPluginInstalled,
}: {
- installWooPayments: () => void;
+ installWooPayments: ( slug: string | undefined ) => void;
isPluginInstalling: boolean;
isPluginInstalled: boolean;
} ) => {
@@ -124,7 +159,13 @@ const InstallWooPaymentsStep = ( {
} );
}
- installWooPayments();
+ // Validate and sanitize the plugin slug before passing to installWooPayments.
+ // This prevents unexpected or malicious input from reaching the plugin installer.
+ // If validation fails, undefined is passed, which triggers the default slug fallback.
+ const validatedSlug = validatePluginSlug(
+ wooPaymentsProvider?.plugin?.slug
+ );
+ installWooPayments( validatedSlug );
} }
isBusy={ isPluginInstalling }
disabled={ isPluginInstalling }
@@ -148,67 +189,88 @@ export const PaymentsContent = ( {} ) => {
const [ isPluginInstalling, setIsPluginInstalling ] =
useState< boolean >( false );
const { installAndActivatePlugins } = useDispatch( pluginsStore );
+ const isMountedRef = useRef( true );
+
+ // Cleanup on unmount to prevent state updates after component is unmounted.
+ useEffect( () => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [] );
- const installWooPayments = useCallback( () => {
- // Set the plugin installation state to true to show a loading indicator.
- setIsPluginInstalling( true );
-
- recordPaymentsEvent( 'recommendations_setup', {
- extension_selected: wooPaymentsExtensionSlug,
- extension_action: ! isWooPaymentsInstalled ? 'install' : 'activate',
- provider_id: wooPaymentsProviderId,
- suggestion_id: wooPaymentsSuggestionId,
- provider_extension_slug: wooPaymentsExtensionSlug,
- from: 'lys',
- source: wooPaymentsOnboardingSessionEntryLYS,
- } );
-
- // Install and activate the WooPayments plugin.
- installAndActivatePlugins( [ wooPaymentsExtensionSlug ] )
- .then( async ( response ) => {
- createNoticesFromResponse( response );
- setWooPaymentsRecentlyActivated( true );
- // Refresh store data after installation.
- // This will trigger a re-render and initialize the onboarding flow.
- refreshStoreData();
-
- if ( ! isWooPaymentsInstalled ) {
- // Record the extension installation event.
- recordPaymentsEvent( 'provider_installed', {
+ const installWooPayments = useCallback(
+ ( realPluginSlug: string | undefined ) => {
+ // Set the plugin installation state to true to show a loading indicator.
+ setIsPluginInstalling( true );
+
+ recordPaymentsEvent( 'recommendations_setup', {
+ extension_selected: wooPaymentsExtensionSlug, // Use the official slug, not the real one.
+ extension_action: ! isWooPaymentsInstalled
+ ? 'install'
+ : 'activate',
+ provider_id: wooPaymentsProviderId,
+ suggestion_id: wooPaymentsSuggestionId,
+ provider_extension_slug: wooPaymentsExtensionSlug, // Use the official slug, not the real one.
+ from: 'lys',
+ source: wooPaymentsOnboardingSessionEntryLYS,
+ } );
+
+ // Install and activate the WooPayments plugin.
+ installAndActivatePlugins( [
+ realPluginSlug ?? wooPaymentsExtensionSlug,
+ ] )
+ .then( async ( response ) => {
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+ createNoticesFromResponse( response );
+ setWooPaymentsRecentlyActivated( true );
+ // Refresh store data after installation.
+ // This will trigger a re-render and initialize the onboarding flow.
+ refreshStoreData();
+
+ if ( ! isWooPaymentsInstalled ) {
+ // Record the extension installation event.
+ recordPaymentsEvent( 'provider_installed', {
+ provider_id: wooPaymentsProviderId,
+ suggestion_id: wooPaymentsSuggestionId,
+ provider_extension_slug: wooPaymentsExtensionSlug,
+ from: 'lys',
+ source: wooPaymentsOnboardingSessionEntryLYS,
+ } );
+ }
+ // Note: The provider extension activation is tracked from the backend (the `provider_extension_activated` event).
+
+ setIsPluginInstalling( false );
+ } )
+ .catch( ( response: { errors: Record< string, string > } ) => {
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+ // Handle errors during installation
+ let eventName = 'provider_extension_installation_failed';
+ if ( isWooPaymentsInstalled ) {
+ eventName = 'provider_extension_activation_failed';
+ }
+ recordPaymentsEvent( eventName, {
provider_id: wooPaymentsProviderId,
suggestion_id: wooPaymentsSuggestionId,
provider_extension_slug: wooPaymentsExtensionSlug,
from: 'lys',
source: wooPaymentsOnboardingSessionEntryLYS,
+ reason: 'error',
} );
- }
- // Note: The provider extension activation is tracked from the backend (the `provider_extension_activated` event).
-
- setIsPluginInstalling( false );
- } )
- .catch( ( response: { errors: Record< string, string > } ) => {
- // Handle errors during installation
- let eventName = 'provider_extension_installation_failed';
- if ( isWooPaymentsInstalled ) {
- eventName = 'provider_extension_activation_failed';
- }
- recordPaymentsEvent( eventName, {
- provider_id: wooPaymentsProviderId,
- suggestion_id: wooPaymentsSuggestionId,
- provider_extension_slug: wooPaymentsExtensionSlug,
- from: 'lys',
- source: wooPaymentsOnboardingSessionEntryLYS,
- reason: 'error',
+ createNoticesFromResponse( response );
+ setIsPluginInstalling( false );
} );
- createNoticesFromResponse( response );
- setIsPluginInstalling( false );
- } );
- }, [
- setIsPluginInstalling,
- installAndActivatePlugins,
- refreshStoreData,
- setWooPaymentsRecentlyActivated,
- ] );
+ },
+ [
+ isWooPaymentsInstalled,
+ installAndActivatePlugins,
+ setWooPaymentsRecentlyActivated,
+ refreshStoreData,
+ ]
+ );
return (
<div className="launch-your-store-payments-content">
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-mobile-header.tsx b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-mobile-header.tsx
index e0d63a2a36..ff5911a755 100644
--- a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-mobile-header.tsx
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-mobile-header.tsx
@@ -64,7 +64,10 @@ export const PaymentsMobileHeader = ( props: SidebarComponentProps ) => {
aria-label={ __( 'Go back', 'woocommerce' ) }
/>
<h1 className="mobile-header__title">
- { __( 'Set up WooPayments', 'woocommerce' ) }
+ {
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ sprintf( __( 'Set up %s', 'woocommerce' ), 'WooPayments' )
+ }
</h1>
<div className="mobile-header__steps">
{ /* translators: %1$s: current step number, %2$s: total number of steps */ }
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-sidebar.tsx b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-sidebar.tsx
index da15662247..1cda761d8f 100644
--- a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-sidebar.tsx
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/payments-sidebar.tsx
@@ -3,7 +3,7 @@
/**
* External dependencies
*/
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import { __, sprintf } from '@wordpress/i18n';
// @ts-ignore No types for this exist yet.
import SidebarNavigationItem from '@wordpress/edit-site/build-module/components/sidebar-navigation-item';
@@ -17,6 +17,7 @@ import {
} from '@wordpress/components';
import { useOnboardingContext } from '~/settings-payments/onboarding/providers/woopayments/data/onboarding-context';
import { recordEvent } from '@woocommerce/tracks';
+import type { TaskType } from '@woocommerce/data';
/**
* Internal dependencies
@@ -30,6 +31,7 @@ import { useSetUpPaymentsContext } from '~/launch-your-store/data/setup-payments
import { WooPaymentsProviderOnboardingStep } from '~/settings-payments/onboarding/types';
import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';
import { wooPaymentsOnboardingSessionEntryLYS } from '~/settings-payments/constants';
+import { getPaymentsTaskFromLysTasklist } from '../tasklist';
export const PaymentsSidebar = ( props: SidebarComponentProps ) => {
const { wooPaymentsRecentlyActivated, isWooPaymentsActive } =
@@ -42,11 +44,43 @@ export const PaymentsSidebar = ( props: SidebarComponentProps ) => {
isLoading,
} = useOnboardingContext();
- const { context } = props;
- const payments_task = context.tasklist?.tasks?.find(
- ( task ) => task.id === 'payments'
+ // Fetch payments task using getPaymentsTaskFromLysTasklist helper
+ const [ payments_task, setPaymentsTask ] = useState< TaskType | undefined >(
+ undefined
);
+ useEffect( () => {
+ let isMounted = true;
+
+ const fetchPaymentsTask = async () => {
+ try {
+ const task = await getPaymentsTaskFromLysTasklist();
+
+ // Only update state if component is still mounted.
+ if ( isMounted ) {
+ setPaymentsTask( task );
+ }
+ } catch ( error ) {
+ // Log the error for debugging purposes.
+ // eslint-disable-next-line no-console
+ console.error(
+ 'PaymentsSidebar: Failed to fetch payments task:',
+ error
+ );
+
+ // Component remains in its default state (payments_task = undefined).
+ // This is a safe fallback as the UI handles undefined gracefully.
+ }
+ };
+
+ fetchPaymentsTask();
+
+ // Cleanup function to prevent state updates after unmount.
+ return () => {
+ isMounted = false;
+ };
+ }, [ isWooPaymentsActive, wooPaymentsRecentlyActivated ] ); // Refresh when WooPayments state changes.
+
const currentStepIndex = allSteps.findIndex(
( step ) => step.id === currentStep?.id
);
@@ -95,7 +129,10 @@ export const PaymentsSidebar = ( props: SidebarComponentProps ) => {
} );
} }
>
- { __( 'Set up WooPayments', 'woocommerce' ) }
+ {
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ sprintf( __( 'Set up %s', 'woocommerce' ), 'WooPayments' )
+ }
</Button>
);
@@ -177,11 +214,7 @@ export const PaymentsSidebar = ( props: SidebarComponentProps ) => {
animate={ { opacity: 1, y: 0 } }
transition={ { duration: 0.7, delay: 0.2 } }
>
- { wooPaymentsRecentlyActivated && (
- <InstallWooPaymentsStep
- isStepComplete={ true }
- />
- ) }
+ <InstallWooPaymentsStep isStepComplete={ true } />
{ sortedSteps.map( ( step ) => {
return (
<SidebarNavigationItem
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/test/payments-sidebar.test.tsx b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/test/payments-sidebar.test.tsx
new file mode 100644
index 0000000000..244b6bcb44
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/components/test/payments-sidebar.test.tsx
@@ -0,0 +1,389 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+
+// Mock problematic imports before importing the module under test.
+jest.mock(
+ '@wordpress/edit-site/build-module/components/sidebar-navigation-item',
+ () => ( {
+ __esModule: true,
+ default: ( {
+ children,
+ className,
+ }: {
+ children: React.ReactNode;
+ className?: string;
+ } ) => (
+ <div data-testid="sidebar-navigation-item" className={ className }>
+ { children }
+ </div>
+ ),
+ } )
+);
+
+jest.mock( '@woocommerce/navigation', () => ( {
+ getNewPath: jest.fn(),
+ navigateTo: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/hooks', () => ( {
+ applyFilters: jest.fn( ( _filter, value ) => value ),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/onboarding', () => ( {
+ accessTaskReferralStorage: jest.fn( () => ( {
+ setWithExpiry: jest.fn(),
+ } ) ),
+ createStorageUtils: jest.fn( () => ( {
+ getWithExpiry: jest.fn( () => [] ),
+ setWithExpiry: jest.fn(),
+ } ) ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+ getAdminLink: jest.fn( ( path ) => path ),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+ recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+jest.mock( '~/settings-payments/constants', () => ( {
+ wooPaymentsOnboardingSessionEntryLYS: 'lys',
+} ) );
+
+// Create the mock function at module scope
+const mockGetPaymentsTaskFromLysTasklist = jest.fn().mockResolvedValue( {
+ id: 'payments',
+ title: 'Set up payments',
+ additionalData: {
+ wooPaymentsIsInstalled: false,
+ },
+} );
+
+// Mock the tasklist helper
+jest.mock( '../../tasklist', () => ( {
+ getPaymentsTaskFromLysTasklist: mockGetPaymentsTaskFromLysTasklist,
+} ) );
+
+// Mock the SidebarContainer
+jest.mock( '../sidebar-container', () => ( {
+ SidebarContainer: ( {
+ children,
+ }: {
+ children: React.ReactNode;
+ title: React.ReactNode;
+ onMobileClose: () => void;
+ } ) => <div data-testid="sidebar-container">{ children }</div>,
+} ) );
+
+// Mock the SiteHub
+jest.mock( '~/customize-store/site-hub', () => ( {
+ SiteHub: () => <div data-testid="site-hub">SiteHub</div>,
+} ) );
+
+// Mock the StepPlaceholder
+jest.mock( '../step-placeholder', () => ( {
+ StepPlaceholder: ( { rows }: { rows: number } ) => (
+ <div data-testid="step-placeholder">Loading { rows } rows...</div>
+ ),
+} ) );
+
+// Mock the icons
+jest.mock( '../icons', () => ( {
+ taskIcons: {
+ activePaymentStep: 'active-icon',
+ },
+ taskCompleteIcon: 'complete-icon',
+} ) );
+
+// Mock framer-motion
+jest.mock( '@wordpress/components', () => ( {
+ Button: ( {
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ } ) => (
+ <button data-testid="button" onClick={ onClick }>
+ { children }
+ </button>
+ ),
+ __experimentalItemGroup: ( {
+ children,
+ }: {
+ children: React.ReactNode;
+ className: string;
+ } ) => <div data-testid="item-group">{ children }</div>,
+ __unstableMotion: {
+ div: ( {
+ children,
+ }: {
+ children: React.ReactNode;
+ initial?: object;
+ animate?: object | string;
+ exit?: object;
+ transition?: object;
+ className?: string;
+ } ) => <div data-testid="motion-div">{ children }</div>,
+ },
+} ) );
+
+// Mock clsx to properly handle objects and strings
+jest.mock( 'clsx', () => ( ...args: unknown[] ) => {
+ const classes: string[] = [];
+ for ( const arg of args ) {
+ if ( typeof arg === 'string' ) {
+ classes.push( arg );
+ } else if ( typeof arg === 'object' && arg !== null ) {
+ for ( const [ key, value ] of Object.entries( arg ) ) {
+ if ( value ) {
+ classes.push( key );
+ }
+ }
+ }
+ }
+ return classes.join( ' ' );
+} );
+
+// Mock values for context
+const mockSetUpPaymentsContext = {
+ isWooPaymentsActive: false,
+ isWooPaymentsInstalled: false,
+ wooPaymentsRecentlyActivated: false,
+ setWooPaymentsRecentlyActivated: jest.fn(),
+};
+
+const mockOnboardingContext: {
+ steps: Array< { id: string; label: string; status: string } >;
+ currentStep: { id: string; label: string; status: string } | null;
+ justCompletedStepId: string | null;
+ isLoading: boolean;
+ error: unknown;
+ setCurrentStep: jest.Mock;
+ goToStep: jest.Mock;
+ goToNextStep: jest.Mock;
+ goToPreviousStep: jest.Mock;
+ completeStep: jest.Mock;
+ setError: jest.Mock;
+ clearError: jest.Mock;
+} = {
+ steps: [],
+ currentStep: null,
+ justCompletedStepId: null,
+ isLoading: false,
+ error: null,
+ setCurrentStep: jest.fn(),
+ goToStep: jest.fn(),
+ goToNextStep: jest.fn(),
+ goToPreviousStep: jest.fn(),
+ completeStep: jest.fn(),
+ setError: jest.fn(),
+ clearError: jest.fn(),
+};
+
+// Mock the context hooks
+jest.mock( '~/launch-your-store/data/setup-payments-context', () => ( {
+ useSetUpPaymentsContext: () => mockSetUpPaymentsContext,
+} ) );
+
+jest.mock(
+ '~/settings-payments/onboarding/providers/woopayments/data/onboarding-context',
+ () => ( {
+ useOnboardingContext: () => mockOnboardingContext,
+ } )
+);
+
+/**
+ * Internal dependencies
+ */
+import { PaymentsSidebar } from '../payments-sidebar';
+import type { SidebarComponentProps } from '../../xstate';
+
+// Mock props for the component - using partial type and casting
+// since we're mocking most dependencies
+const mockProps = {
+ sendEventToSidebar: jest.fn(),
+ sendEventToMainContent: jest.fn(),
+ onMobileClose: jest.fn(),
+ className: 'test-class',
+ context: {
+ externalUrl: null,
+ mainContentMachineRef: {} as never, // Mock ref
+ testOrderCount: 0,
+ tasklist: {
+ tasks: [],
+ },
+ },
+} as unknown as SidebarComponentProps;
+
+describe( 'PaymentsSidebar', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ // Reset context mock values.
+ mockSetUpPaymentsContext.isWooPaymentsActive = false;
+ mockSetUpPaymentsContext.isWooPaymentsInstalled = false;
+ mockSetUpPaymentsContext.wooPaymentsRecentlyActivated = false;
+ mockOnboardingContext.steps = [];
+ mockOnboardingContext.currentStep = null;
+ mockOnboardingContext.justCompletedStepId = null;
+ mockOnboardingContext.isLoading = false;
+ // Reset the tasklist mock to its default resolved value.
+ mockGetPaymentsTaskFromLysTasklist.mockResolvedValue( {
+ id: 'payments',
+ title: 'Set up payments',
+ additionalData: {
+ wooPaymentsIsInstalled: false,
+ },
+ } );
+ } );
+
+ describe( 'InstallWooPaymentsStep visibility', () => {
+ it( 'renders InstallWooPaymentsStep with isStepComplete=false when WooPayments is NOT active', () => {
+ mockSetUpPaymentsContext.isWooPaymentsActive = false;
+
+ render( <PaymentsSidebar { ...mockProps } /> );
+
+ // Should show the Install step
+ const sidebarItems = screen.getAllByTestId(
+ 'sidebar-navigation-item'
+ );
+ expect( sidebarItems ).toHaveLength( 1 );
+
+ // The item should have the install-woopayments class but NOT is-complete.
+ const installStep = sidebarItems[ 0 ];
+ expect( installStep ).toHaveClass( 'install-woopayments' );
+ expect( installStep ).not.toHaveClass( 'is-complete' );
+ } );
+
+ it( 'renders InstallWooPaymentsStep with isStepComplete=true when WooPayments IS active and NOT loading', () => {
+ mockSetUpPaymentsContext.isWooPaymentsActive = true;
+ mockOnboardingContext.isLoading = false;
+
+ render( <PaymentsSidebar { ...mockProps } /> );
+
+ // Should show the Install step as completed
+ const sidebarItems = screen.getAllByTestId(
+ 'sidebar-navigation-item'
+ );
+ expect( sidebarItems.length ).toBeGreaterThanOrEqual( 1 );
+
+ // The first item should be the install step with is-complete class
+ const installStep = sidebarItems[ 0 ];
+ expect( installStep ).toHaveClass( 'install-woopayments' );
+ expect( installStep ).toHaveClass( 'is-complete' );
+ } );
+
+ it( 'shows loading placeholder when WooPayments IS active and IS loading', () => {
+ mockSetUpPaymentsContext.isWooPaymentsActive = true;
+ mockOnboardingContext.isLoading = true;
+
+ render( <PaymentsSidebar { ...mockProps } /> );
+
+ // Should show the placeholder
+ expect(
+ screen.getByTestId( 'step-placeholder' )
+ ).toBeInTheDocument();
+
+ // Should NOT show the Install step
+ expect(
+ screen.queryByTestId( 'sidebar-navigation-item' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'displays "Install WooPayments" text when WooPayments is NOT installed', async () => {
+ mockSetUpPaymentsContext.isWooPaymentsActive = false;
+
+ // Mock the task to indicate WooPayments is not installed.
+ mockGetPaymentsTaskFromLysTasklist.mockResolvedValue( {
+ id: 'payments',
+ title: 'Set up payments',
+ additionalData: {
+ wooPaymentsIsInstalled: false,
+ },
+ } );
+
+ render( <PaymentsSidebar { ...mockProps } /> );
+
+ // Wait for the async task to resolve and state to update.
+ await waitFor( () => {
+ const sidebarItems = screen.getAllByTestId(
+ 'sidebar-navigation-item'
+ );
+ expect( sidebarItems[ 0 ] ).toHaveTextContent(
+ /Install.*WooPayments/i
+ );
+ } );
+ } );
+
+ it( 'displays "Enable WooPayments" text when WooPayments IS installed but not active', async () => {
+ mockSetUpPaymentsContext.isWooPaymentsActive = false;
+ mockSetUpPaymentsContext.isWooPaymentsInstalled = true;
+
+ // Mock the task to indicate WooPayments is installed.
+ mockGetPaymentsTaskFromLysTasklist.mockResolvedValue( {
+ id: 'payments',
+ title: 'Set up payments',
+ additionalData: {
+ wooPaymentsIsInstalled: true,
+ },
+ } );
+
+ render( <PaymentsSidebar { ...mockProps } /> );
+
+ // Wait for the async task to resolve and state to update.
+ await waitFor( () => {
+ const sidebarItems = screen.getAllByTestId(
+ 'sidebar-navigation-item'
+ );
+ expect( sidebarItems[ 0 ] ).toHaveTextContent(
+ /Enable.*WooPayments/i
+ );
+ } );
+ } );
+
+ it( 'renders additional onboarding steps when WooPayments is active', () => {
+ mockSetUpPaymentsContext.isWooPaymentsActive = true;
+ mockOnboardingContext.isLoading = false;
+ mockOnboardingContext.steps = [
+ {
+ id: 'connect',
+ label: 'Connect with WordPress.com',
+ status: 'pending',
+ },
+ {
+ id: 'payment_methods',
+ label: 'Choose your payment methods',
+ status: 'pending',
+ },
+ ];
+
+ render( <PaymentsSidebar { ...mockProps } /> );
+
+ // Should show Install step + 2 onboarding steps = 3 items
+ const sidebarItems = screen.getAllByTestId(
+ 'sidebar-navigation-item'
+ );
+ expect( sidebarItems ).toHaveLength( 3 );
+
+ // First should be Install step (completed)
+ expect( sidebarItems[ 0 ] ).toHaveClass( 'install-woopayments' );
+ expect( sidebarItems[ 0 ] ).toHaveClass( 'is-complete' );
+
+ // Other steps should be present
+ expect( sidebarItems[ 1 ] ).toHaveTextContent(
+ 'Connect with WordPress.com'
+ );
+ expect( sidebarItems[ 2 ] ).toHaveTextContent(
+ 'Choose your payment methods'
+ );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/tasklist.tsx b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/tasklist.tsx
index cc0af3a1a4..b03f6e6f65 100644
--- a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/tasklist.tsx
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/tasklist.tsx
@@ -4,7 +4,7 @@
* External dependencies
*/
import { onboardingStore, TaskType } from '@woocommerce/data';
-import { navigateTo, getNewPath } from '@woocommerce/navigation';
+import { getNewPath, navigateTo } from '@woocommerce/navigation';
import { resolveSelect } from '@wordpress/data';
import { applyFilters } from '@wordpress/hooks';
import clsx from 'clsx';
@@ -83,6 +83,37 @@ export const getLysTasklist = async () => {
};
};
+/**
+ * Helper function to fetch the payments task from the LYS tasklist.
+ * Handles validation and error logging.
+ *
+ * @return {Promise<TaskType | undefined>} The payments task or undefined if not found or on error.
+ */
+export const getPaymentsTaskFromLysTasklist = async (): Promise<
+ TaskType | undefined
+> => {
+ try {
+ const tasklist = await getLysTasklist();
+
+ // Validate that fullLysTaskList is an array
+ if ( ! Array.isArray( tasklist?.fullLysTaskList ) ) {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'Invalid tasklist data: fullLysTaskList is not an array'
+ );
+ return undefined;
+ }
+
+ return tasklist.fullLysTaskList.find(
+ ( task ) => task.id === 'payments'
+ );
+ } catch ( error ) {
+ // eslint-disable-next-line no-console
+ console.error( 'Error fetching payments task:', error );
+ return undefined;
+ }
+};
+
export function taskClickedAction( event: {
type: 'TASK_CLICKED';
task: TaskType;
diff --git a/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/test/tasklist.test.ts b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/test/tasklist.test.ts
new file mode 100644
index 0000000000..1498a5d1f5
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/launch-your-store/hub/sidebar/test/tasklist.test.ts
@@ -0,0 +1,323 @@
+// Mock problematic imports before importing the module under test.
+jest.mock(
+ '@wordpress/edit-site/build-module/components/sidebar-navigation-item',
+ () => ( {
+ __esModule: true,
+ default: () => null,
+ } )
+);
+
+jest.mock( '@woocommerce/navigation', () => ( {
+ getNewPath: jest.fn(),
+ navigateTo: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/hooks', () => ( {
+ applyFilters: jest.fn( ( _filter, value ) => value ),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/onboarding', () => ( {
+ accessTaskReferralStorage: jest.fn( () => ( {
+ setWithExpiry: jest.fn(),
+ } ) ),
+ createStorageUtils: jest.fn( () => ( {
+ getWithExpiry: jest.fn( () => [] ),
+ setWithExpiry: jest.fn(),
+ } ) ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+ getAdminLink: jest.fn( ( path ) => path ),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+ recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+// Mock the entire @woocommerce/data module to avoid complex initialization.
+jest.mock( '@woocommerce/data', () => ( {
+ onboardingStore: 'onboarding-store',
+} ) );
+
+// Create a mock function for resolveSelect's chain.
+const mockGetTaskListsByIds = jest.fn();
+
+jest.mock( '@wordpress/data', () => ( {
+ resolveSelect: jest.fn( () => ( {
+ getTaskListsByIds: mockGetTaskListsByIds,
+ } ) ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { getPaymentsTaskFromLysTasklist } from '../tasklist';
+
+/**
+ * TaskType interface for tests.
+ * Matches the structure from @woocommerce/data.
+ */
+interface TaskType {
+ id: string;
+ parentId: string;
+ title: string;
+ content: string;
+ isComplete: boolean;
+ time: string;
+ actionLabel?: string;
+ actionUrl?: string;
+ isVisible: boolean;
+ isDismissable: boolean;
+ isDismissed: boolean;
+ isSnoozeable: boolean;
+ isSnoozed: boolean;
+ snoozedUntil: number;
+ canView: boolean;
+ isActioned: boolean;
+ eventPrefix: string;
+ level: 1 | 2 | 3;
+ isDisabled: boolean;
+ additionalInfo: string;
+ isVisited: boolean;
+ isInProgress: boolean;
+ inProgressLabel: string;
+ recordViewEvent: boolean;
+ additionalData?: Record< string, unknown >;
+}
+
+/**
+ * Creates a mock TaskType object with default values.
+ *
+ * @param overrides - Partial TaskType to override default values.
+ * @return A complete mock TaskType object.
+ */
+const createMockTask = ( overrides: Partial< TaskType > = {} ): TaskType => ( {
+ id: 'test-task',
+ parentId: '',
+ title: 'Test Task',
+ content: '',
+ isComplete: false,
+ time: '5 minutes',
+ actionLabel: 'Start',
+ actionUrl: 'https://example.com/task',
+ isVisible: true,
+ isDismissable: false,
+ isDismissed: false,
+ isSnoozeable: false,
+ isSnoozed: false,
+ snoozedUntil: 0,
+ canView: true,
+ isActioned: false,
+ eventPrefix: 'test',
+ level: 1,
+ isDisabled: false,
+ additionalInfo: '',
+ isVisited: false,
+ isInProgress: false,
+ inProgressLabel: '',
+ recordViewEvent: false,
+ ...overrides,
+} );
+
+/**
+ * Creates a mock tasklist array for getTaskListsByIds.
+ *
+ * @param tasks - The tasks to include in the tasklist.
+ * @return A mock tasklist array.
+ */
+const createMockTasklistResponse = ( tasks: TaskType[] ) => [
+ {
+ id: 'setup',
+ title: 'Setup',
+ isHidden: false,
+ isVisible: true,
+ isComplete: false,
+ eventPrefix: 'tasklist',
+ displayProgressHeader: true,
+ keepCompletedTaskList: 'no' as const,
+ tasks,
+ },
+];
+
+describe( 'getPaymentsTaskFromLysTasklist', () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ // Spy on console.error to verify error logging.
+ consoleErrorSpy = jest
+ .spyOn( console, 'error' )
+ .mockImplementation( () => {} );
+ } );
+
+ afterEach( () => {
+ consoleErrorSpy.mockRestore();
+ } );
+
+ describe( 'successful retrieval', () => {
+ it( 'returns the payments task when fullLysTaskList contains a task with id "payments"', async () => {
+ const paymentsTask = createMockTask( {
+ id: 'payments',
+ title: 'Set up payments',
+ } );
+ const otherTask = createMockTask( {
+ id: 'shipping',
+ title: 'Set up shipping',
+ } );
+
+ mockGetTaskListsByIds.mockResolvedValue(
+ createMockTasklistResponse( [ paymentsTask, otherTask ] )
+ );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toEqual( paymentsTask );
+ expect( consoleErrorSpy ).not.toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'invalid tasklist data', () => {
+ // Note: When tasks is not an array, getLysTasklist() throws an error
+ // when trying to call .filter() on it. The error is caught by the
+ // try/catch in getPaymentsTaskFromLysTasklist and logged.
+ it( 'returns undefined and logs an error when tasks is not an array', async () => {
+ // Return a tasklist where tasks is not an array.
+ mockGetTaskListsByIds.mockResolvedValue( [
+ {
+ id: 'setup',
+ title: 'Setup',
+ isHidden: false,
+ isVisible: true,
+ isComplete: false,
+ eventPrefix: 'tasklist',
+ displayProgressHeader: true,
+ keepCompletedTaskList: 'no' as const,
+ tasks: 'not-an-array',
+ },
+ ] );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ // Error is caught from getLysTasklist when it tries to filter.
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error fetching payments task:',
+ expect.any( TypeError )
+ );
+ } );
+
+ it( 'returns undefined and logs an error when tasks is null', async () => {
+ mockGetTaskListsByIds.mockResolvedValue( [
+ {
+ id: 'setup',
+ title: 'Setup',
+ isHidden: false,
+ isVisible: true,
+ isComplete: false,
+ eventPrefix: 'tasklist',
+ displayProgressHeader: true,
+ keepCompletedTaskList: 'no' as const,
+ tasks: null,
+ },
+ ] );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ // Error is caught from getLysTasklist when it tries to filter.
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error fetching payments task:',
+ expect.any( TypeError )
+ );
+ } );
+
+ it( 'returns undefined and logs an error when tasks is undefined', async () => {
+ mockGetTaskListsByIds.mockResolvedValue( [
+ {
+ id: 'setup',
+ title: 'Setup',
+ isHidden: false,
+ isVisible: true,
+ isComplete: false,
+ eventPrefix: 'tasklist',
+ displayProgressHeader: true,
+ keepCompletedTaskList: 'no' as const,
+ tasks: undefined,
+ },
+ ] );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ // Error is caught from getLysTasklist when it tries to filter.
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error fetching payments task:',
+ expect.any( TypeError )
+ );
+ } );
+ } );
+
+ describe( 'payments task absent', () => {
+ it( 'returns undefined when the payments task is absent from fullLysTaskList', async () => {
+ const shippingTask = createMockTask( {
+ id: 'shipping',
+ title: 'Set up shipping',
+ } );
+ const taxTask = createMockTask( {
+ id: 'tax',
+ title: 'Set up tax',
+ } );
+
+ mockGetTaskListsByIds.mockResolvedValue(
+ createMockTasklistResponse( [ shippingTask, taxTask ] )
+ );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ expect( consoleErrorSpy ).not.toHaveBeenCalled();
+ } );
+
+ it( 'returns undefined when fullLysTaskList is an empty array', async () => {
+ mockGetTaskListsByIds.mockResolvedValue(
+ createMockTasklistResponse( [] )
+ );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ expect( consoleErrorSpy ).not.toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'error handling', () => {
+ it( 'returns undefined and logs an error when getLysTasklist throws', async () => {
+ const testError = new Error( 'Network error' );
+ mockGetTaskListsByIds.mockRejectedValue( testError );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error fetching payments task:',
+ testError
+ );
+ } );
+
+ it( 'returns undefined and logs an error when getLysTasklist throws a non-Error value', async () => {
+ mockGetTaskListsByIds.mockRejectedValue( 'String error' );
+
+ const result = await getPaymentsTaskFromLysTasklist();
+
+ expect( result ).toBeUndefined();
+ expect( consoleErrorSpy ).toHaveBeenCalledWith(
+ 'Error fetching payments task:',
+ 'String error'
+ );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/payments-welcome/strings.tsx b/plugins/woocommerce/client/admin/client/payments-welcome/strings.tsx
index e1b2623740..0f93e4ed87 100644
--- a/plugins/woocommerce/client/admin/client/payments-welcome/strings.tsx
+++ b/plugins/woocommerce/client/admin/client/payments-welcome/strings.tsx
@@ -18,9 +18,13 @@ export default {
),
limitedTimeOffer: __( 'Limited time offer', 'woocommerce' ),
TosAndPp: createInterpolateElement(
- __(
- 'By using WooPayments you agree to our <a1>Terms of Service</a2> and acknowledge that you have read our <a2>Privacy Policy</a2>. Discount will be applied to payments processed via WooPayments upon completion of installation, setup, and connection. ',
- 'woocommerce'
+ sprintf(
+ /* translators: 1: Payment provider name (e.g., WooPayments) */
+ __(
+ 'By using %1$s you agree to our <a1>Terms of Service</a1> and acknowledge that you have read our <a2>Privacy Policy</a2>. Discount will be applied to payments processed via %1$s upon completion of installation, setup, and connection. ',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
{
a1: (
@@ -42,9 +46,13 @@ export default {
}
),
TosAndPpWooPay: createInterpolateElement(
- __(
- 'By using WooPayments you agree to our <a1>Terms of Service</a2> (including WooPay <a3>merchant terms</a3>) and acknowledge that you have read our <a2>Privacy Policy</a2>. Discount will be applied to payments processed via WooPayments upon completion of installation, setup, and connection. ',
- 'woocommerce'
+ sprintf(
+ /* translators: 1: Payment provider name (e.g., WooPayments) */
+ __(
+ 'By using %1$s you agree to our <a1>Terms of Service</a1> (including WooPay <a3>merchant terms</a3>) and acknowledge that you have read our <a2>Privacy Policy</a2>. Discount will be applied to payments processed via %1$s upon completion of installation, setup, and connection. ',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
{
a1: (
@@ -86,17 +94,29 @@ export default {
),
}
),
- paymentOptions: __(
- 'WooPayments is pre-integrated with all popular payment options',
- 'woocommerce'
+ paymentOptions: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __(
+ '%s is pre-integrated with all popular payment options',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
andMore: __( '& more', 'woocommerce' ),
learnMore: __( 'Learn more', 'woocommerce' ),
survey: {
- title: __( 'No thanks, I don’t want WooPayments', 'woocommerce' ),
- intro: __(
- 'Note that the extension hasn’t been installed. This will simply dismiss our limited time offer. Please take a moment to tell us why you’d like to dismiss the WooPayments offer.',
- 'woocommerce'
+ title: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __( 'No thanks, I don’t want %s', 'woocommerce' ),
+ 'WooPayments'
+ ),
+ intro: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __(
+ 'Note that the extension hasn’t been installed. This will simply dismiss our limited time offer. Please take a moment to tell us why you’d like to dismiss the %s offer.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
question: __(
'Why would you like to dismiss the new payments experience?',
@@ -110,9 +130,10 @@ export default {
'I don’t want to install another plugin',
'woocommerce'
),
- moreInfoLabel: __(
- 'I need more information about WooPayments',
- 'woocommerce'
+ moreInfoLabel: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __( 'I need more information about %s', 'woocommerce' ),
+ 'WooPayments'
),
anotherTimeLabel: __(
'I’m open to installing it another time',
@@ -123,7 +144,11 @@ export default {
'woocommerce'
),
commentsLabel: __( 'Comments (Optional)', 'woocommerce' ),
- cancelButton: __( 'Just dismiss WooPayments', 'woocommerce' ),
+ cancelButton: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __( 'Just dismiss %s', 'woocommerce' ),
+ 'WooPayments'
+ ),
submitButton: __( 'Dismiss and send feedback', 'woocommerce' ),
},
faq: {
@@ -138,43 +163,60 @@ export default {
seeMore: __( 'See more', 'woocommerce' ),
paypal: {
title: __( 'PayPal Payments', 'woocommerce' ),
- description: __(
- 'Enable PayPal Payments alongside WooPayments. Give your customers another way to pay safely and conveniently via PayPal, PayLater, and Venmo.',
- 'woocommerce'
+ description: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __(
+ 'Enable PayPal Payments alongside %s. Give your customers another way to pay safely and conveniently via PayPal, PayLater, and Venmo.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
},
amazonpay: {
title: __( 'Amazon Pay', 'woocommerce' ),
- description: __(
- 'Enable Amazon Pay alongside WooPayments and give buyers the ability to pay via Amazon Pay. Transactions take place via Amazon embedded widgets, so the buyer never leaves your site.',
- 'woocommerce'
+ description: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __(
+ 'Enable Amazon Pay alongside %s and give buyers the ability to pay via Amazon Pay. Transactions take place via Amazon embedded widgets, so the buyer never leaves your site.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
},
klarna: {
title: __( 'Klarna', 'woocommerce' ),
- description: __(
- 'Enable Klarna alongside WooPayments. With Klarna Payments buyers can choose the payment installment option they want, Pay Now, Pay Later, or Slice It. No credit card numbers, no passwords, no worries.',
- 'woocommerce'
+ description: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __(
+ 'Enable Klarna alongside %s. With Klarna Payments buyers can choose the payment installment option they want, Pay Now, Pay Later, or Slice It. No credit card numbers, no passwords, no worries.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
},
affirm: {
title: __( 'Affirm', 'woocommerce' ),
- description: __(
- 'Enable Affirm alongside WooPayments and give buyers the ability to pick the payment option that works for them and their budget — from 4 interest-free payments every 2 weeks to monthly installments.',
- 'woocommerce'
+ description: sprintf(
+ /* translators: %s: Payment provider name (e.g., WooPayments) */
+ __(
+ 'Enable Affirm alongside %s and give buyers the ability to pick the payment option that works for them and their budget — from 4 interest-free payments every 2 weeks to monthly installments.',
+ 'woocommerce'
+ ),
+ 'WooPayments'
),
},
installText: ( extensionsString: string ) => {
const extensionsNumber = extensionsString.split( ', ' ).length;
return createInterpolateElement(
sprintf(
- /* translators: %s = names of the installed extensions */
+ /* translators: 1: Payment provider name (e.g., WooPayments), 2: names of the installed extensions */
_n(
- 'Installing <strong>WooPayments</strong> will automatically activate <strong>%s</strong> extension in your store.',
- 'Installing <strong>WooPayments</strong> will automatically activate <strong>%s</strong> extensions in your store.',
+ 'Installing <strong>%1$s</strong> will automatically activate <strong>%2$s</strong> extension in your store.',
+ 'Installing <strong>%1$s</strong> will automatically activate <strong>%2$s</strong> extensions in your store.',
extensionsNumber,
'woocommerce'
),
+ 'WooPayments',
extensionsString
),
{
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/modals/woo-payments-update-required-modal.tsx b/plugins/woocommerce/client/admin/client/settings-payments/components/modals/woo-payments-update-required-modal.tsx
index 608c1f98d4..5e5a3cbe7e 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/modals/woo-payments-update-required-modal.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/modals/woo-payments-update-required-modal.tsx
@@ -80,7 +80,11 @@ export const WooPaymentsUpdateRequiredModal = ( {
disabled={ isUpdating }
onClick={ handleUpdateWooPayments }
>
- { __( 'Update WooPayments', 'woocommerce' ) }
+ { sprintf(
+ /* translators: %s: Provider name */
+ __( 'Update %s', 'woocommerce' ),
+ 'WooPayments'
+ ) }
</Button>
<Button
variant="secondary"
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/components/modal/style.scss b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/components/modal/style.scss
index 6036440f8e..5a8d824dc9 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/components/modal/style.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/components/modal/style.scss
@@ -154,6 +154,13 @@
flex: 1;
height: 100%;
}
+
+ .components-notice {
+ margin: $gap-small auto;
+ padding: 0 $gap-small;
+ width: 95%;
+ max-width: 620px;
+ }
}
&__loading {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/index.tsx
index 9f0dfb360f..a7c835ad63 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/index.tsx
@@ -2,6 +2,8 @@
* External dependencies
*/
import React from 'react';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { Notice } from '@wordpress/components';
/**
* Internal dependencies
@@ -18,6 +20,34 @@ import Step from './components/step';
import { getMccFromIndustry, getComingSoonShareKey } from './utils';
import './style.scss';
import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';
+import type { OnboardingError } from '~/settings-payments/onboarding/types';
+
+/**
+ * Maximum number of individual error messages to display before showing a summary.
+ */
+const MAX_DISPLAYED_ERRORS = 3;
+
+/**
+ * Fallback error message used when an error object lacks a valid message.
+ */
+const FALLBACK_ERROR_MESSAGE = __(
+ 'Something went wrong. Please try again.',
+ 'woocommerce'
+);
+
+/**
+ * Normalizes an error message string to ensure it's safe for rendering.
+ * Returns the fallback message if the input is not a valid, non-empty string.
+ *
+ * @param message - The error message to normalize.
+ * @return A safe string suitable for rendering.
+ */
+const normalizeErrorMessage = ( message: unknown ): string => {
+ if ( typeof message === 'string' && message.trim().length > 0 ) {
+ return message.trim();
+ }
+ return FALLBACK_ERROR_MESSAGE;
+};
export const BusinessVerificationStep: React.FC = () => {
const { currentStep, closeModal, sessionEntryPoint } =
@@ -70,6 +100,50 @@ export const BusinessVerificationStep: React.FC = () => {
<div className="settings-payments-onboarding-modal__step-business-verification">
<WooPaymentsStepHeader onClose={ closeModal } />
<div className="settings-payments-onboarding-modal__step-business-verification-content">
+ { currentStep?.errors && currentStep.errors.length > 0 && (
+ <Notice
+ status="error"
+ isDismissible={ false }
+ className="settings-payments-onboarding-modal__step-business-verification-error"
+ // Adding role="alert" for explicit screen reader announcement.
+ // While @wordpress/components Notice uses speak() internally,
+ // role="alert" provides better backwards compatibility with older AT.
+ // Type assertion needed as Notice component types don't include standard HTML attributes.
+ { ...( {
+ role: 'alert',
+ } as React.HTMLAttributes< HTMLDivElement > ) }
+ >
+ { currentStep.errors.length <= MAX_DISPLAYED_ERRORS ? (
+ // Display individual error messages when count is manageable.
+ ( currentStep.errors as OnboardingError[] ).map(
+ ( error, index ) => (
+ <p key={ error?.code ?? index }>
+ { normalizeErrorMessage(
+ error?.message
+ ) }
+ </p>
+ )
+ )
+ ) : (
+ // Display a summary when there are too many errors.
+ <>
+ <p>
+ { sprintf(
+ /* translators: %d: number of errors */
+ _n(
+ '%d error occurred during setup.',
+ '%d errors occurred during setup.',
+ currentStep.errors.length,
+ 'woocommerce'
+ ),
+ currentStep.errors.length
+ ) }
+ </p>
+ <p>{ FALLBACK_ERROR_MESSAGE }</p>
+ </>
+ ) }
+ </Notice>
+ ) }
<BusinessVerificationContextProvider
initialData={ initialData }
>
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/index.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/index.test.tsx
new file mode 100644
index 0000000000..f4d02c48e4
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/index.test.tsx
@@ -0,0 +1,394 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { OnboardingError } from '~/settings-payments/onboarding/types';
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import { BusinessVerificationStep } from '../index';
+
+// Mock all child components and dependencies.
+jest.mock( '../../../data/onboarding-context', () => ( {
+ useOnboardingContext: jest.fn(),
+} ) );
+
+jest.mock( '../../../components/header', () => ( {
+ __esModule: true,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- onClose is required by the component interface.
+ default: ( { onClose }: { onClose: () => void } ) => (
+ <div data-testid="step-header">Header</div>
+ ),
+} ) );
+
+jest.mock( '../data/business-verification-context', () => ( {
+ BusinessVerificationContextProvider: ( {
+ children,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- initialData is required by the component interface.
+ initialData,
+ }: {
+ children: React.ReactNode;
+ initialData: Record< string, unknown >;
+ } ) => <div data-testid="bv-context-provider">{ children }</div>,
+} ) );
+
+jest.mock( '../components/form', () => ( {
+ OnboardingForm: ( { children }: { children: React.ReactNode } ) => (
+ <div data-testid="onboarding-form">{ children }</div>
+ ),
+} ) );
+
+jest.mock( '../sections/business-details', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="business-details">Business Details</div>,
+} ) );
+
+jest.mock( '../sections/embedded-kyc', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="embedded-kyc">Embedded KYC</div>,
+} ) );
+
+jest.mock( '../sections/activate-payments', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="activate-payments">Activate Payments</div>,
+} ) );
+
+jest.mock( '../components/stepper', () => ( {
+ Stepper: ( { children }: { children: React.ReactNode } ) => (
+ <div data-testid="stepper">{ children }</div>
+ ),
+} ) );
+
+jest.mock( '../components/step', () => ( {
+ __esModule: true,
+ default: ( {
+ children,
+ name,
+ }: {
+ children: React.ReactNode;
+ name: string;
+ } ) => <div data-testid={ `step-${ name }` }>{ children }</div>,
+} ) );
+
+jest.mock( '../utils', () => ( {
+ getMccFromIndustry: jest.fn( () => 'mcc_code' ),
+ getComingSoonShareKey: jest.fn( () => '' ),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+ recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+
+// Helper to create a mock context with configurable errors.
+const createMockContext = (
+ errors: OnboardingError[] = [],
+ overrides: Record< string, unknown > = {}
+) => ( {
+ currentStep: {
+ id: 'business_verification',
+ status: 'not_started',
+ context: {
+ fields: {
+ mccs_display_tree: [],
+ location: 'US',
+ },
+ self_assessment: {},
+ sub_steps: {
+ business: { status: 'not_started' },
+ embedded: { status: 'not_started' },
+ },
+ has_test_account: false,
+ has_sandbox_account: false,
+ },
+ errors,
+ ...overrides,
+ },
+ closeModal: jest.fn(),
+ sessionEntryPoint: 'settings',
+} );
+
+describe( 'BusinessVerificationStep', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ // Mock window.wcSettings.
+ Object.defineProperty( window, 'wcSettings', {
+ value: {
+ siteTitle: 'Test Store',
+ homeUrl: 'https://example.com',
+ },
+ writable: true,
+ } );
+ } );
+
+ describe( 'Error Notice Rendering', () => {
+ it( 'does not render error notice when there are no errors', () => {
+ mockUseOnboardingContext.mockReturnValue( createMockContext( [] ) );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ const notice = container.querySelector( '.is-error' );
+ expect( notice ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders error notice when errors exist', () => {
+ const errors: OnboardingError[] = [
+ { message: 'Test error', code: 'test_error' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ const notice = container.querySelector( '.is-error' );
+ expect( notice ).toBeInTheDocument();
+ } );
+
+ it( 'renders a single error message', () => {
+ const errors: OnboardingError[] = [
+ { message: 'Single error message', code: 'single_error' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ // Query within the notice content to avoid matching a11y-speak region.
+ const noticeContent = container.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeContent ).toBeInTheDocument();
+ expect( noticeContent?.textContent ).toContain(
+ 'Single error message'
+ );
+ } );
+
+ it( 'renders multiple error messages when count is within limit', () => {
+ const errors: OnboardingError[] = [
+ { message: 'First error', code: 'error_1' },
+ { message: 'Second error', code: 'error_2' },
+ { message: 'Third error', code: 'error_3' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ render( <BusinessVerificationStep /> );
+
+ expect( screen.getByText( 'First error' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Second error' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Third error' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders summary when error count exceeds limit', () => {
+ const errors: OnboardingError[] = [
+ { message: 'Error 1', code: 'error_1' },
+ { message: 'Error 2', code: 'error_2' },
+ { message: 'Error 3', code: 'error_3' },
+ { message: 'Error 4', code: 'error_4' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ render( <BusinessVerificationStep /> );
+
+ // Should show summary instead of individual errors.
+ expect(
+ screen.getByText( '4 errors occurred during setup.' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Something went wrong. Please try again.' )
+ ).toBeInTheDocument();
+ // Individual errors should not be shown.
+ expect( screen.queryByText( 'Error 1' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders fallback message when error message is empty', () => {
+ // Backend may return empty message string when error details unavailable.
+ const errors: OnboardingError[] = [
+ { message: '', code: 'no_message_error' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ // There should be an error notice with the fallback message.
+ const notice = container.querySelector( '.is-error' );
+ expect( notice ).toBeInTheDocument();
+ // Query within notice content to avoid a11y-speak region.
+ const noticeContent = container.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeContent?.textContent ).toContain(
+ 'Something went wrong. Please try again.'
+ );
+ } );
+
+ it( 'renders fallback message when error message is whitespace only', () => {
+ const errors: OnboardingError[] = [
+ { message: ' ', code: 'whitespace_message' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ const notice = container.querySelector( '.is-error' );
+ expect( notice ).toBeInTheDocument();
+ // Query within notice content to avoid a11y-speak region.
+ const noticeContent = container.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeContent?.textContent ).toContain(
+ 'Something went wrong. Please try again.'
+ );
+ } );
+
+ it( 'trims whitespace from error messages', () => {
+ const errors: OnboardingError[] = [
+ {
+ message: ' Unique padded message ',
+ code: 'padded_message',
+ },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ // Query within notice content to avoid a11y-speak region.
+ const noticeContent = container.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeContent?.textContent ).toContain(
+ 'Unique padded message'
+ );
+ } );
+
+ it( 'renders error messages in paragraph elements', () => {
+ const errors: OnboardingError[] = [
+ { message: 'Error with code', code: 'unique_code' },
+ ];
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( errors )
+ );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ // The paragraph should be rendered within the notice content.
+ const noticeContent = container.querySelector(
+ '.components-notice__content'
+ );
+ expect( noticeContent ).toBeInTheDocument();
+ const paragraphs = noticeContent?.querySelectorAll( 'p' );
+ expect( paragraphs?.length ).toBe( 1 );
+ expect( paragraphs?.[ 0 ].textContent ).toBe( 'Error with code' );
+ } );
+
+ it( 'handles errors array with undefined currentStep gracefully', () => {
+ mockUseOnboardingContext.mockReturnValue( {
+ currentStep: undefined,
+ closeModal: jest.fn(),
+ sessionEntryPoint: 'settings',
+ } );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ // Should not throw and should not render error notice.
+ const notice = container.querySelector( '.is-error' );
+ expect( notice ).not.toBeInTheDocument();
+ } );
+
+ it( 'handles empty errors array', () => {
+ mockUseOnboardingContext.mockReturnValue( createMockContext( [] ) );
+
+ const { container } = render( <BusinessVerificationStep /> );
+
+ const notice = container.querySelector( '.is-error' );
+ expect( notice ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Basic Rendering', () => {
+ it( 'renders the step header', () => {
+ mockUseOnboardingContext.mockReturnValue( createMockContext( [] ) );
+
+ render( <BusinessVerificationStep /> );
+
+ expect( screen.getByTestId( 'step-header' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders the stepper component', () => {
+ mockUseOnboardingContext.mockReturnValue( createMockContext( [] ) );
+
+ render( <BusinessVerificationStep /> );
+
+ expect( screen.getByTestId( 'stepper' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders business and embedded steps', () => {
+ mockUseOnboardingContext.mockReturnValue( createMockContext( [] ) );
+
+ render( <BusinessVerificationStep /> );
+
+ expect( screen.getByTestId( 'step-business' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'step-embedded' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders activate step when user has test account', () => {
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( [], {
+ context: {
+ fields: { mccs_display_tree: [], location: 'US' },
+ self_assessment: {},
+ sub_steps: {
+ activate: { status: 'not_started' },
+ business: { status: 'not_started' },
+ embedded: { status: 'not_started' },
+ },
+ has_test_account: true,
+ has_sandbox_account: false,
+ },
+ } )
+ );
+
+ render( <BusinessVerificationStep /> );
+
+ expect( screen.getByTestId( 'step-activate' ) ).toBeInTheDocument();
+ } );
+
+ it( 'does not render activate step when user has no test account', () => {
+ mockUseOnboardingContext.mockReturnValue(
+ createMockContext( [], {
+ context: {
+ fields: { mccs_display_tree: [], location: 'US' },
+ self_assessment: {},
+ sub_steps: {
+ business: { status: 'not_started' },
+ embedded: { status: 'not_started' },
+ },
+ has_test_account: false,
+ has_sandbox_account: false,
+ },
+ } )
+ );
+
+ render( <BusinessVerificationStep /> );
+
+ expect(
+ screen.queryByTestId( 'step-activate' )
+ ).not.toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/finish/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/finish/index.tsx
index 36bad3febe..4d9649fb96 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/finish/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/finish/index.tsx
@@ -3,7 +3,7 @@
*/
import React from 'react';
import { __ } from '@wordpress/i18n';
-import { Button } from '@wordpress/components';
+import { Button, Notice } from '@wordpress/components';
/**
* Internal dependencies
@@ -14,12 +14,22 @@ import './style.scss';
import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';
export const FinishStep: React.FC = () => {
- const { context, closeModal, sessionEntryPoint } = useOnboardingContext();
+ const { context, currentStep, closeModal, sessionEntryPoint } =
+ useOnboardingContext();
return (
<>
<WooPaymentsStepHeader onClose={ closeModal } />
<div className="settings-payments-onboarding-modal__step--content">
+ { currentStep?.errors && currentStep.errors.length > 0 && (
+ <Notice
+ status="error"
+ isDismissible={ false }
+ className="settings-payments-onboarding-modal__step--content-finish-error"
+ >
+ <p>{ currentStep.errors[ 0 ].message }</p>
+ </Notice>
+ ) }
<div className="settings-payments-onboarding-modal__step--content-finish">
<h1 className="settings-payments-onboarding-modal__step--content-finish-title">
{ __(
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/index.tsx
index f02306c6d1..096b957e18 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/index.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
-import { Button, Icon } from '@wordpress/components';
+import { Button, Icon, Notice } from '@wordpress/components';
import { RecommendedPaymentMethod } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useState, useEffect, useMemo, useRef } from '@wordpress/element';
@@ -232,6 +232,15 @@ export default function PaymentMethodsSelection() {
) }
</div>
</div>
+ { currentStep?.errors && currentStep.errors.length > 0 && (
+ <Notice
+ status="error"
+ isDismissible={ false }
+ className="woocommerce-recommended-payment-methods__error"
+ >
+ <p>{ currentStep.errors[ 0 ].message }</p>
+ </Notice>
+ ) }
<div className="woocommerce-recommended-payment-methods__list">
<div
className="settings-payments-methods__container"
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/style.scss b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/style.scss
index aa2860a1bc..52aa635710 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/style.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/payment-methods-selection/style.scss
@@ -182,6 +182,22 @@
}
}
+ .woocommerce-recommended-payment-methods__error.components-notice {
+ margin: $gap-small $gap-larger;
+
+ @media screen and (max-width: 1024px) {
+ width: auto;
+ max-width: 100%;
+ margin: $gap-small $gap-large;
+ }
+
+ @media screen and (max-width: $break-medium) {
+ width: auto;
+ max-width: 100%;
+ margin: $gap-small $gap;
+ }
+ }
+
.woocommerce-recommended-payment-methods__list {
flex: 1;
min-height: 0;
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-or-live-account/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-or-live-account/index.tsx
index c6a1b1471b..d718700d83 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-or-live-account/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-or-live-account/index.tsx
@@ -4,7 +4,7 @@
import React, { useState } from 'react';
import { __ } from '@wordpress/i18n';
import interpolateComponents from '@automattic/interpolate-components';
-import { Button } from '@wordpress/components';
+import { Button, Notice } from '@wordpress/components';
import { Link } from '@woocommerce/components';
import apiFetch from '@wordpress/api-fetch';
@@ -60,6 +60,26 @@ const TestOrLiveAccountStep = () => {
</p>
</div>
</div>
+ { currentStep?.errors &&
+ currentStep.errors.length > 0 && (
+ <Notice
+ status="error"
+ isDismissible={ false }
+ className="woocommerce-payments-test-or-live-account-step__error"
+ // Adding role="alert" for explicit screen reader announcement.
+ // While @wordpress/components Notice uses speak() internally,
+ // role="alert" provides better backwards compatibility with older AT.
+ { ...{ role: 'alert' } }
+ >
+ <p>
+ { currentStep.errors[ 0 ]?.message ||
+ __(
+ 'Something went wrong. Please try again.',
+ 'woocommerce'
+ ) }
+ </p>
+ </Notice>
+ ) }
<div className="woocommerce-payments-test-or-live-account-step__success-whats-next">
<div className="woocommerce-woopayments-modal__content__item-flex">
<img
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx
index ce8dc3bb45..1759c4474c 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx
@@ -3,7 +3,7 @@
*/
import React from 'react';
import { __ } from '@wordpress/i18n';
-import { Button } from '@wordpress/components';
+import { Button, Notice } from '@wordpress/components';
import { useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
@@ -24,6 +24,25 @@ export const JetpackStep: React.FC = () => {
return (
<>
<WooPaymentsStepHeader onClose={ closeModal } />
+ { currentStep?.errors && currentStep.errors.length > 0 && (
+ <Notice
+ status="error"
+ isDismissible={ false }
+ className="settings-payments-onboarding-modal__step-jetpack-error"
+ // Adding role="alert" for explicit screen reader announcement.
+ // While @wordpress/components Notice uses speak() internally,
+ // role="alert" provides better backwards compatibility with older AT.
+ { ...{ role: 'alert' } }
+ >
+ <p>
+ { currentStep.errors[ 0 ]?.message ||
+ __(
+ 'Something went wrong. Please try again.',
+ 'woocommerce'
+ ) }
+ </p>
+ </Notice>
+ ) }
<div className="settings-payments-onboarding-modal__step--content">
<div className="settings-payments-onboarding-modal__step--content-jetpack">
<h1 className="settings-payments-onboarding-modal__step--content-jetpack-title">
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts
index ba0f10b142..bba7ca04af 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts
@@ -15,6 +15,19 @@ import {
MccsDisplayTreeItem,
} from './providers/woopayments/steps/business-verification/types'; // To-do: Maybe move to @woocommerce/data
+/**
+ * Error object returned from the onboarding backend.
+ *
+ * The backend always sanitizes errors to include both fields.
+ * Fields may be empty strings if not provided by the upstream source.
+ *
+ * @see WooPaymentsService::sanitize_onboarding_step_errors()
+ */
+export interface OnboardingError {
+ message: string;
+ code: string;
+}
+
/**
* Props for the Onboarding Modal component.
*/
@@ -128,10 +141,7 @@ export interface WooPaymentsProviderOnboardingStep {
// True when a sandbox (test-mode, non-test-drive) account is connected.
has_sandbox_account?: boolean;
};
- errors?: {
- message: string;
- code: string;
- }[];
+ errors?: OnboardingError[];
}
/**
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php
index e46c5a3245..89ba63014b 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/WooCommercePayments.php
@@ -40,7 +40,8 @@ class WooCommercePayments extends Task {
* @return string
*/
public function get_title() {
- return __( 'Get paid with WooPayments', 'woocommerce' );
+ /* translators: %s: Payment provider name. */
+ return sprintf( __( 'Get paid with %s', 'woocommerce' ), 'WooPayments' );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsMoreInfoNeeded.php b/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsMoreInfoNeeded.php
index 3af7bd884b..53a902aa46 100644
--- a/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsMoreInfoNeeded.php
+++ b/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsMoreInfoNeeded.php
@@ -63,10 +63,12 @@ class PaymentsMoreInfoNeeded {
if ( ! self::should_display_note() ) {
return;
}
- $content = __( 'We recently asked you if you wanted more information about WooPayments. Run your business and manage your payments in one place with the solution built and supported by WooCommerce.', 'woocommerce' );
+ /* translators: %s: Payment provider name. */
+ $content = sprintf( __( 'We recently asked you if you wanted more information about %s. Run your business and manage your payments in one place with the solution built and supported by WooCommerce.', 'woocommerce' ), 'WooPayments' );
$note = new Note();
- $note->set_title( __( 'Payments made simple with WooPayments', 'woocommerce' ) );
+ /* translators: %s: Payment provider name. */
+ $note->set_title( sprintf( __( 'Payments made simple with %s', 'woocommerce' ), 'WooPayments' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
diff --git a/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsRemindMeLater.php b/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsRemindMeLater.php
index a2b0412ec5..7bfdd07d4b 100644
--- a/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsRemindMeLater.php
+++ b/plugins/woocommerce/src/Internal/Admin/Notes/PaymentsRemindMeLater.php
@@ -63,10 +63,12 @@ class PaymentsRemindMeLater {
if ( ! self::should_display_note() ) {
return;
}
- $content = __( 'Save up to $800 in fees by managing transactions with WooPayments. With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies.', 'woocommerce' );
+ /* translators: 1: Payment provider name. */
+ $content = sprintf( __( 'Save up to $800 in fees by managing transactions with %1$s. With %1$s, you can securely accept major cards, Apple Pay, and payments in over 100 currencies.', 'woocommerce' ), 'WooPayments' );
$note = new Note();
- $note->set_title( __( 'Save big with WooPayments', 'woocommerce' ) );
+ /* translators: %s: Payment provider name. */
+ $note->set_title( sprintf( __( 'Save big with %s', 'woocommerce' ), 'WooPayments' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
diff --git a/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php b/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php
index d60e5b4df2..db17613bf5 100644
--- a/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php
+++ b/plugins/woocommerce/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensions.php
@@ -476,7 +476,8 @@ class DefaultFreeExtensions {
public static function with_core_profiler_fields( array $plugins ) {
$_plugins = array(
'woocommerce-payments' => array(
- 'label' => __( 'Get paid with WooPayments', 'woocommerce' ),
+ /* translators: %s: Payment provider name. */
+ 'label' => sprintf( __( 'Get paid with %s', 'woocommerce' ), 'WooPayments' ),
'image_url' => self::get_woo_logo(),
'description' => __( "Securely accept payments and manage payment activity straight from your store's dashboard", 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/woocommerce-payments?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php b/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php
index 53adc9d0a3..e37b0fae2e 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php
@@ -78,7 +78,7 @@ class Payments {
public function get_payment_providers( string $location, bool $for_display = true, bool $remove_shells = false ): array {
$payment_gateways = $this->providers->get_payment_gateways( $for_display );
if ( ! $for_display && $remove_shells ) {
- $payment_gateways = $this->providers->remove_shell_payment_gateways( $payment_gateways );
+ $payment_gateways = $this->providers->remove_shell_payment_gateways( $payment_gateways, $location );
}
$providers_order_map = $this->providers->get_order_map();
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
index dfd1fb40f4..77c0403359 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
@@ -738,12 +738,32 @@ class WooPaymentsService {
'context' => array(),
);
+ // Move all extra keys (not code, message, context) into the context.
+ $reserved_keys = array( 'code', 'message', 'context' );
+ foreach ( $error as $key => $value ) {
+ if ( ! in_array( $key, $reserved_keys, true ) ) {
+ $sanitized_error['context'][ $key ] = $value;
+ }
+ }
+
+ // Merge any existing context data.
if ( isset( $error['context'] ) && ( is_array( $error['context'] ) || is_object( $error['context'] ) ) ) {
// Make sure we are dealing with an array.
- $sanitized_error['context'] = json_decode( wp_json_encode( $error['context'] ), true );
- if ( ! is_array( $sanitized_error['context'] ) ) {
- $sanitized_error['context'] = array();
+ $existing_context = json_decode( wp_json_encode( $error['context'] ), true );
+ if ( is_array( $existing_context ) ) {
+ $sanitized_error['context'] = array_merge( $sanitized_error['context'], $existing_context );
}
+ }
+
+ // Flatten any nested 'context' key (e.g., from WP_Error data that includes its own context).
+ // The nested context values take precedence over the top-level values.
+ if ( isset( $sanitized_error['context']['context'] ) && is_array( $sanitized_error['context']['context'] ) ) {
+ $nested_context = $sanitized_error['context']['context'];
+ unset( $sanitized_error['context']['context'] );
+ $sanitized_error['context'] = array_merge( $sanitized_error['context'], $nested_context );
+ }
+
+ if ( ! empty( $sanitized_error['context'] ) ) {
// Sanitize the context data.
// It can only contain strings or arrays of strings.
@@ -2031,6 +2051,44 @@ class WooPaymentsService {
}
}
}
+ // Standardize errors to be a list of arrays with `code`, `message`, and optional extra keys.
+ $standardized_errors = array();
+ // If the errors is not a list of errors or it has any of the reserved entries,
+ // treat it as a single error.
+ if ( ! is_array( $step_details['errors'] )
+ || array_key_exists( 'code', $step_details['errors'] )
+ || array_key_exists( 'message', $step_details['errors'] )
+ || array_key_exists( 'context', $step_details['errors'] )
+ ) {
+ $raw_errors = array( $step_details['errors'] );
+ } else {
+ $raw_errors = $step_details['errors'];
+ }
+
+ foreach ( $raw_errors as $error ) {
+ if ( $error instanceof \WP_Error ) {
+ $error = array(
+ 'code' => $error->get_error_code(),
+ 'message' => $error->get_error_message(),
+ 'context' => $error->get_error_data(),
+ );
+ } elseif ( is_array( $error ) ) {
+ if ( empty( $error['code'] ) ) {
+ $error['code'] = 'general_error';
+ }
+ if ( ! array_key_exists( 'message', $error ) ) {
+ $error['message'] = '';
+ }
+ } else {
+ $error = array(
+ 'code' => 'general_error',
+ 'message' => (string) $error,
+ );
+ }
+
+ $standardized_errors[] = $this->sanitize_onboarding_step_error( $error );
+ }
+ $step_details['errors'] = $standardized_errors;
// Ensure that any step has the general actions.
if ( empty( $step_details['actions'] ) ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
index c70c72771c..1f29e1142f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
@@ -5835,6 +5835,8 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
try {
$this->sut->mark_onboarding_step_completed( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location );
+
+ $this->fail( 'Expected ApiException not thrown' );
} catch ( ApiException $e ) {
$this->assertEquals( 'woocommerce_woopayments_onboarding_step_requirements_not_met', $e->getErrorCode() );
}
@@ -5898,6 +5900,8 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
try {
$this->sut->mark_onboarding_step_completed( $step_id, $location );
+
+ $this->fail( 'Expected ApiException not thrown' );
} catch ( ApiException $e ) {
$this->assertEquals( 'woocommerce_woopayments_onboarding_step_blocked', $e->getErrorCode() );
}
@@ -7225,6 +7229,556 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
$this->sut->onboarding_test_account_init( $location );
}
+ /**
+ * Test that error sanitization moves extra keys to context when storing a step error.
+ *
+ * When an error has keys beyond 'code', 'message', and 'context', those extra keys
+ * should be moved into the 'context' array.
+ *
+ * @return void
+ */
+ public function test_error_sanitization_moves_extra_keys_to_context() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange the NOX profile.
+ $step_id = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
+ $stored_profile = array();
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ // Return the latest updated profile if available.
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ $updated_stored_profiles[] = $value;
+ return true;
+ }
+
+ return true;
+ },
+ )
+ );
+
+ // Arrange the REST API requests to return a WP_Error with extra data.
+ $error_data_with_extra_keys = array(
+ 'details' => 'Some additional details',
+ 'trace' => 'stack trace info',
+ 'response' => 'raw response data',
+ );
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) use ( $error_data_with_extra_keys ) {
+ unset( $params ); // Avoid parameter not used PHPCS errors.
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return new WP_Error( 'test_error', 'Test error message', $error_data_with_extra_keys );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ // Act - call the method which will trigger mark_onboarding_step_failed.
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected exception was not thrown' );
+ } catch ( \Exception $e ) {
+ // Expected exception, ignore it.
+ unset( $e );
+ }
+
+ // Assert - verify the stored error has extra keys moved to context.
+ $this->assertNotEmpty( $updated_stored_profiles, 'Profile should have been updated' );
+ $final_profile = end( $updated_stored_profiles );
+
+ $this->assertArrayHasKey( 'onboarding', $final_profile );
+ $this->assertArrayHasKey( $location, $final_profile['onboarding'] );
+ $this->assertArrayHasKey( 'steps', $final_profile['onboarding'][ $location ] );
+ $this->assertArrayHasKey( $step_id, $final_profile['onboarding'][ $location ]['steps'] );
+ $this->assertArrayHasKey( 'data', $final_profile['onboarding'][ $location ]['steps'][ $step_id ] );
+ $this->assertArrayHasKey( 'error', $final_profile['onboarding'][ $location ]['steps'][ $step_id ]['data'] );
+
+ $stored_error = $final_profile['onboarding'][ $location ]['steps'][ $step_id ]['data']['error'];
+
+ // Verify the error structure has code, message, and context at the top level.
+ $this->assertArrayHasKey( 'code', $stored_error );
+ $this->assertArrayHasKey( 'message', $stored_error );
+ $this->assertArrayHasKey( 'context', $stored_error );
+ $this->assertSame( 'test_error', $stored_error['code'] );
+ $this->assertSame( 'Test error message', $stored_error['message'] );
+
+ // Verify the extra keys were moved to context.
+ $this->assertIsArray( $stored_error['context'] );
+ $this->assertNotEmpty( $stored_error['context'], 'Context should contain the extra keys' );
+ $this->assertArrayHasKey( 'details', $stored_error['context'] );
+ $this->assertArrayHasKey( 'trace', $stored_error['context'] );
+ $this->assertArrayHasKey( 'response', $stored_error['context'] );
+ $this->assertSame( 'Some additional details', $stored_error['context']['details'] );
+ $this->assertSame( 'stack trace info', $stored_error['context']['trace'] );
+ $this->assertSame( 'raw response data', $stored_error['context']['response'] );
+ }
+
+ /**
+ * Test that error sanitization merges extra keys with existing context.
+ *
+ * When an error has both a 'context' key and extra keys, the extra keys should be
+ * merged into the context with existing context values taking precedence.
+ *
+ * @return void
+ */
+ public function test_error_sanitization_merges_extra_keys_with_existing_context() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange the NOX profile.
+ $step_id = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
+ $stored_profile = array();
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ $updated_stored_profiles[] = $value;
+ return true;
+ }
+
+ return true;
+ },
+ )
+ );
+
+ // Arrange the REST API requests to return a WP_Error with context containing extra keys.
+ // The error_data has a 'details' key that should be moved to context,
+ // and a nested 'context' with 'existing_key'.
+ $error_data = array(
+ 'details' => 'Extra details',
+ 'context' => array(
+ 'existing_key' => 'existing value',
+ 'conflicting_key' => 'context value',
+ ),
+ 'conflicting_key' => 'extra key value', // This should be overwritten by context value.
+ );
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) use ( $error_data ) {
+ unset( $params ); // Avoid parameter not used PHPCS errors.
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return new WP_Error( 'test_error', 'Test error message', $error_data );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ // Act.
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected exception was not thrown' );
+ } catch ( \Exception $e ) {
+ // Expected exception, ignore it.
+ unset( $e );
+ }
+
+ // Assert.
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $stored_error = $final_profile['onboarding'][ $location ]['steps'][ $step_id ]['data']['error'];
+
+ // Verify the error structure has code, message, and context at the top level.
+ $this->assertArrayHasKey( 'code', $stored_error );
+ $this->assertArrayHasKey( 'message', $stored_error );
+ $this->assertArrayHasKey( 'context', $stored_error );
+ $this->assertIsArray( $stored_error['context'] );
+ $this->assertNotEmpty( $stored_error['context'], 'Context should contain the merged keys' );
+
+ // Verify conflicting_key is NOT at the top level (it should only be in context).
+ $this->assertArrayNotHasKey( 'conflicting_key', $stored_error, 'Extra keys should be moved to context, not kept at top level' );
+
+ // Verify extra key was moved to context.
+ $this->assertArrayHasKey( 'details', $stored_error['context'] );
+ $this->assertSame( 'Extra details', $stored_error['context']['details'] );
+
+ // Verify existing context key is preserved.
+ $this->assertArrayHasKey( 'existing_key', $stored_error['context'] );
+ $this->assertSame( 'existing value', $stored_error['context']['existing_key'] );
+
+ // Verify that the context value takes precedence over the extra key value for conflicting keys.
+ $this->assertArrayHasKey( 'conflicting_key', $stored_error['context'] );
+ $this->assertSame( 'context value', $stored_error['context']['conflicting_key'] );
+ }
+
+ /**
+ * Test that error sanitization flattens nested context keys.
+ *
+ * When an error's context contains a nested 'context' key (e.g., from WP_Error data),
+ * the nested context should be flattened, with nested context values taking precedence.
+ *
+ * @return void
+ */
+ public function test_error_sanitization_flattens_nested_context() {
+ $location = 'US';
+
+ // Arrange the WPCOM connection.
+ // Make it working.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( true );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->any() )
+ ->method( 'has_connected_owner' )
+ ->willReturn( true );
+
+ // Arrange the NOX profile.
+ $step_id = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
+ $stored_profile = array();
+ $updated_stored_profiles = array();
+ $this->mockable_proxy->register_function_mocks(
+ array(
+ 'get_option' => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+ }
+
+ return $default_value;
+ },
+ 'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+ if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+ $updated_stored_profiles[] = $value;
+ return true;
+ }
+
+ return true;
+ },
+ )
+ );
+
+ // Arrange the REST API requests to return a WP_Error with nested context.
+ // The error_data has a nested 'context' key that should be flattened.
+ $error_data = array(
+ 'top_level_key' => 'top value',
+ 'conflicting_key' => 'top level value', // Should be overwritten by nested context value.
+ 'context' => array(
+ 'nested_key' => 'nested value',
+ 'conflicting_key' => 'nested context value', // Takes precedence.
+ ),
+ );
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) use ( $error_data ) {
+ unset( $params ); // Avoid parameter not used PHPCS errors.
+ if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+ return new WP_Error( 'test_error', 'Test error message', $error_data );
+ }
+
+ throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+ },
+ ),
+ )
+ );
+
+ // Act.
+ try {
+ $this->sut->onboarding_test_account_init( $location );
+ $this->fail( 'Expected exception was not thrown' );
+ } catch ( \Exception $e ) {
+ // Exception expected from the mocked WP_Error response.
+ // The actual error sanitization is verified in the assertions below.
+ unset( $e );
+ }
+
+ // Assert.
+ $this->assertNotEmpty( $updated_stored_profiles );
+ $final_profile = end( $updated_stored_profiles );
+ $stored_error = $final_profile['onboarding'][ $location ]['steps'][ $step_id ]['data']['error'];
+
+ $this->assertArrayHasKey( 'context', $stored_error );
+ $this->assertIsArray( $stored_error['context'] );
+
+ // Verify the top-level key was preserved.
+ $this->assertArrayHasKey( 'top_level_key', $stored_error['context'] );
+ $this->assertSame( 'top value', $stored_error['context']['top_level_key'] );
+
+ // Verify the nested context key was flattened into the main context.
+ $this->assertArrayHasKey( 'nested_key', $stored_error['context'] );
+ $this->assertSame( 'nested value', $stored_error['context']['nested_key'] );
+
+ // Verify the nested 'context' key itself is no longer present.
+ $this->assertArrayNotHasKey( 'context', $stored_error['context'] );
+
+ // Verify that nested context value takes precedence over top-level value for conflicting keys.
+ $this->assertArrayHasKey( 'conflicting_key', $stored_error['context'] );
+ $this->assertSame( 'nested context value', $stored_error['context']['conflicting_key'] );
+ }
+
+ /**
+ * Test that step error standardization treats an error object with reserved keys as a single error.
+ *
+ * When a step's errors field directly contains an error object (with code/message/context keys)
+ * instead of a list of errors, it should be treated as a single error and wrapped in an array.
+ *
+ * @return void
+ */
+ public function test_step_error_standardization_wraps_single_error_object() {
+ $location = 'US';
+
+ // Create step details with errors as a single error object (not a list).
+ // This simulates an edge case where errors might be passed incorrectly.
+ $step_details = array(
+ 'id' => WooPaymentsService::ONBOARDING_STEP_PAYMENT_METHODS,
+ 'status' => WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED,
+ // This is a single error object, NOT a list of errors.
+ // It has 'code', 'message', and 'context' keys directly.
+ 'errors' => array(
+ 'code' => 'test_error_code',
+ 'message' => 'Test error message',
+ 'context' => array(
+ 'some_key' => 'some_value',
+ ),
+ ),
+ );
+
+ // Use reflection to call the private standardize_onboarding_step_details method.
+ $reflection = new \ReflectionClass( $this->sut );
+ $method = $reflection->getMethod( 'standardize_onboarding_step_details' );
+ $method->setAccessible( true );
+
+ // Act.
+ $result = $method->invoke( $this->sut, $step_details, $location, '/rest/path/' );
+
+ // Assert.
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertIsArray( $result['errors'] );
+ $this->assertCount( 1, $result['errors'] );
+
+ // Verify the single error was properly standardized.
+ $standardized_error = $result['errors'][0];
+ $this->assertArrayHasKey( 'code', $standardized_error );
+ $this->assertSame( 'test_error_code', $standardized_error['code'] );
+ $this->assertArrayHasKey( 'message', $standardized_error );
+ $this->assertSame( 'Test error message', $standardized_error['message'] );
+ $this->assertArrayHasKey( 'context', $standardized_error );
+ $this->assertArrayHasKey( 'some_key', $standardized_error['context'] );
+ $this->assertSame( 'some_value', $standardized_error['context']['some_key'] );
+ }
+
+ /**
+ * Test that step error standardization treats errors with only 'code' key as a single error.
+ *
+ * An error object with just the 'code' key should be treated as a single error.
+ *
+ * @return void
+ */
+ public function test_step_error_standardization_wraps_error_with_code_key() {
+ $location = 'US';
+
+ // Create step details with errors containing only a 'code' key.
+ $step_details = array(
+ 'id' => WooPaymentsService::ONBOARDING_STEP_PAYMENT_METHODS,
+ 'status' => WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED,
+ // Has 'code' key directly - should be treated as single error.
+ 'errors' => array(
+ 'code' => 'error_with_only_code',
+ ),
+ );
+
+ // Use reflection to call the private method.
+ $reflection = new \ReflectionClass( $this->sut );
+ $method = $reflection->getMethod( 'standardize_onboarding_step_details' );
+ $method->setAccessible( true );
+
+ // Act.
+ $result = $method->invoke( $this->sut, $step_details, $location, '/rest/path/' );
+
+ // Assert.
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertIsArray( $result['errors'] );
+ $this->assertCount( 1, $result['errors'] );
+
+ $standardized_error = $result['errors'][0];
+ $this->assertArrayHasKey( 'code', $standardized_error );
+ $this->assertSame( 'error_with_only_code', $standardized_error['code'] );
+ // Message should default to empty string when not provided.
+ $this->assertArrayHasKey( 'message', $standardized_error );
+ $this->assertSame( '', $standardized_error['message'], 'Message should default to empty string' );
+ // Context should default to empty array when not provided.
+ $this->assertArrayHasKey( 'context', $standardized_error );
+ $this->assertSame( array(), $standardized_error['context'], 'Context should default to empty array' );
+ }
+
+ /**
+ * Test that step error standardization treats errors with only 'message' key as a single error.
+ *
+ * An error object with just the 'message' key should be treated as a single error.
+ *
+ * @return void
+ */
+ public function test_step_error_standardization_wraps_error_with_message_key() {
+ $location = 'US';
+
+ // Create step details with errors containing only a 'message' key.
+ $step_details = array(
+ 'id' => WooPaymentsService::ONBOARDING_STEP_PAYMENT_METHODS,
+ 'status' => WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED,
+ // Has 'message' key directly - should be treated as single error.
+ 'errors' => array(
+ 'message' => 'Error with only message',
+ ),
+ );
+
+ // Use reflection to call the private method.
+ $reflection = new \ReflectionClass( $this->sut );
+ $method = $reflection->getMethod( 'standardize_onboarding_step_details' );
+ $method->setAccessible( true );
+
+ // Act.
+ $result = $method->invoke( $this->sut, $step_details, $location, '/rest/path/' );
+
+ // Assert.
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertIsArray( $result['errors'] );
+ $this->assertCount( 1, $result['errors'] );
+
+ $standardized_error = $result['errors'][0];
+ $this->assertSame( 'Error with only message', $standardized_error['message'] );
+ // Code should default to 'general_error'.
+ $this->assertArrayHasKey( 'code', $standardized_error );
+ $this->assertSame( 'general_error', $standardized_error['code'] );
+ }
+
+ /**
+ * Test that step error standardization treats errors with only 'context' key as a single error.
+ *
+ * An error object with just the 'context' key should be treated as a single error.
+ *
+ * @return void
+ */
+ public function test_step_error_standardization_wraps_error_with_context_key() {
+ $location = 'US';
+
+ // Create step details with errors containing only a 'context' key.
+ $step_details = array(
+ 'id' => WooPaymentsService::ONBOARDING_STEP_PAYMENT_METHODS,
+ 'status' => WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED,
+ // Has 'context' key directly - should be treated as single error.
+ 'errors' => array(
+ 'context' => array(
+ 'detail' => 'Some context detail',
+ ),
+ ),
+ );
+
+ // Use reflection to call the private method.
+ $reflection = new \ReflectionClass( $this->sut );
+ $method = $reflection->getMethod( 'standardize_onboarding_step_details' );
+ $method->setAccessible( true );
+
+ // Act.
+ $result = $method->invoke( $this->sut, $step_details, $location, '/rest/path/' );
+
+ // Assert.
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertIsArray( $result['errors'] );
+ $this->assertCount( 1, $result['errors'] );
+
+ $standardized_error = $result['errors'][0];
+ // Code should default to 'general_error'.
+ $this->assertArrayHasKey( 'code', $standardized_error );
+ $this->assertSame( 'general_error', $standardized_error['code'] );
+ // Context should be preserved.
+ $this->assertArrayHasKey( 'context', $standardized_error );
+ $this->assertArrayHasKey( 'detail', $standardized_error['context'] );
+ $this->assertSame( 'Some context detail', $standardized_error['context']['detail'] );
+ }
+
+ /**
+ * Test that step error standardization handles a proper list of errors correctly.
+ *
+ * When errors is already a list of error objects (without code/message/context at top level),
+ * it should NOT be wrapped again.
+ *
+ * @return void
+ */
+ public function test_step_error_standardization_keeps_error_list_as_is() {
+ $location = 'US';
+
+ // Create step details with errors as a proper list of error objects.
+ $step_details = array(
+ 'id' => WooPaymentsService::ONBOARDING_STEP_PAYMENT_METHODS,
+ 'status' => WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED,
+ // This is a proper list of errors - should NOT be wrapped.
+ 'errors' => array(
+ array(
+ 'code' => 'first_error',
+ 'message' => 'First error message',
+ ),
+ array(
+ 'code' => 'second_error',
+ 'message' => 'Second error message',
+ ),
+ ),
+ );
+
+ // Use reflection to call the private method.
+ $reflection = new \ReflectionClass( $this->sut );
+ $method = $reflection->getMethod( 'standardize_onboarding_step_details' );
+ $method->setAccessible( true );
+
+ // Act.
+ $result = $method->invoke( $this->sut, $step_details, $location, '/rest/path/' );
+
+ // Assert.
+ $this->assertArrayHasKey( 'errors', $result );
+ $this->assertIsArray( $result['errors'] );
+ $this->assertCount( 2, $result['errors'] );
+
+ // Verify both errors were preserved.
+ $this->assertSame( 'first_error', $result['errors'][0]['code'] );
+ $this->assertSame( 'First error message', $result['errors'][0]['message'] );
+ $this->assertSame( 'second_error', $result['errors'][1]['code'] );
+ $this->assertSame( 'Second error message', $result['errors'][1]['message'] );
+ }
+
/**
* Test onboarding_test_account_init that throws an exception when the REST API call doesn't return success.
*