Merge branch 'trunk' into new-code-freeze-flow

This commit is contained in:
Naman Malhotra 2024-10-30 12:28:57 +03:00 committed by GitHub
commit 3debc1f2d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 4543 additions and 1605 deletions

View File

@ -7,7 +7,7 @@ permissions: {}
jobs:
prep:
if: github.event.label.name == 'Approved' || github.event.label.name == 'Rejected'
if: github.event.label.name == 'code freeze exception' || github.event.label.name == 'point release request' || github.event.label.name == 'Approved' || github.event.label.name == 'Rejected'
runs-on: ubuntu-20.04
outputs:
release_number: ${{ steps.extract-release.outputs.RELEASE_NUMBER }}
@ -37,6 +37,23 @@ jobs:
core.setFailed("No valid release number found after the 'Which release does this request apply to?' section. Aborting.");
}
apply-milestone:
if: github.event.label.name == 'code freeze exception' || github.event.label.name == 'point release request' || github.event.label.name == 'Approved' || github.event.label.name == 'Rejected'
runs-on: ubuntu-20.04
permissions:
issues: write
needs:
- prep
steps:
- name: Apply Milestone to the Issue
env:
MILESTONE: ${{ needs.prep.outputs.release_number }}.0
ISSUE_URL: ${{ github.event.issue.html_url }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Applying milestone: $MILESTONE"
gh issue edit "$ISSUE_URL" --milestone "$MILESTONE"
cfe-created:
if: github.event.label.name == 'code freeze exception' || github.event.label.name == 'point release request'
runs-on: ubuntu-20.04
@ -77,8 +94,6 @@ jobs:
request-approved:
if: ${{ github.event.label.name == 'Approved' }}
runs-on: ubuntu-20.04
needs:
- prep
permissions:
pull-requests: write
issues: write
@ -127,15 +142,6 @@ jobs:
run: |
gh pr edit $PR_NUMBER --add-label "cherry pick to frozen release" --repo "$OWNER/$REPO"
- name: Apply Milestone to the Issue
env:
MILESTONE: ${{ needs.prep.outputs.release_number }}.0
ISSUE_URL: ${{ github.event.issue.html_url }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Applying milestone: $MILESTONE"
gh issue edit "$ISSUE_URL" --milestone "$MILESTONE"
- name: Comment issue has been approved
env:
ISSUE_URL: ${{ github.event.issue.html_url }}
@ -177,26 +183,15 @@ jobs:
request-rejected:
if: ${{ github.event.label.name == 'Rejected' }}
runs-on: ubuntu-20.04
needs:
- prep
permissions:
issues: write
steps:
- name: Apply Milestone to the Issue
env:
MILESTONE: ${{ needs.prep.outputs.release_number }}.0
ISSUE_URL: ${{ github.event.issue.html_url }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Applying milestone: $MILESTONE"
gh issue edit "$ISSUE_URL" --milestone "$MILESTONE"
- name: Close the request
env:
ISSUE_URL: ${{ github.event.issue.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue close "$ISSUE_URL" --comment "Closing issue the request is rejected - $ISSUE_URL. Please switch the base to trunk and merge."
gh issue close "$ISSUE_URL" --comment "Closing issue as the request is rejected - $ISSUE_URL. Please switch the base to trunk and merge."
- name: Set Slack Message
id: set-message

View File

@ -105,7 +105,7 @@
"react",
"react-dom",
"@types/react-dom",
"@types/react",
"@types/react"
],
"pinVersion": "18.3.x",
"packages": [
@ -119,7 +119,7 @@
"react",
"react-dom",
"@types/react-dom",
"@types/react",
"@types/react"
],
"pinVersion": "17.0.x",
"packages": [
@ -213,6 +213,7 @@
"packages": [
"@woocommerce/block-templates",
"@woocommerce/product-editor",
"@woocommerce/settings-editor",
"@woocommerce/admin-library",
"@woocommerce/components"
],
@ -448,7 +449,7 @@
"node",
"pnpm",
"postcss",
"postcss-loader",
"postcss-loader"
],
"packages": [
"**"

View File

@ -17,6 +17,7 @@ module.exports = [
'@woocommerce/notices',
'@woocommerce/number',
'@woocommerce/product-editor',
'@woocommerce/settings-editor',
'@woocommerce/tracks',
'@woocommerce/remote-logging',
// wc-blocks packages

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Settings package, feature flag, and initial page.

View File

@ -0,0 +1,44 @@
module.exports = {
extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ],
root: true,
overrides: [
{
files: [ '**/*.js', '**/*.jsx', '**/*.tsx' ],
rules: {
'react/react-in-jsx-scope': 'off',
},
},
],
settings: {
'import/core-modules': [
'@woocommerce/admin-layout',
'@woocommerce/block-templates',
'@woocommerce/components',
'@woocommerce/customer-effort-score',
'@woocommerce/currency',
'@woocommerce/data',
'@woocommerce/experimental',
'@woocommerce/expression-evaluation',
'@woocommerce/navigation',
'@woocommerce/number',
'@woocommerce/settings',
'@woocommerce/tracks',
'@wordpress/blocks',
'@wordpress/block-editor',
'@wordpress/components',
'@wordpress/core-data',
'@wordpress/date',
'@wordpress/element',
'@wordpress/keycodes',
'@wordpress/media-utils',
'@testing-library/react',
'dompurify',
'react-router-dom',
],
'import/resolver': {
node: {},
webpack: {},
typescript: {},
},
},
};

View File

@ -0,0 +1 @@
package-lock=false

View File

@ -0,0 +1,11 @@
# Product Editor
A collection of WooCommerce Admin settings editor components and utilities.
## Installation
Install the module
```bash
pnpm install @woocommerce/settings-editor --save
```

View File

@ -0,0 +1,3 @@
module.exports = {
extends: '../internal-js-tests/babel.config.js',
};

View File

@ -0,0 +1,3 @@
# Changelog
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Settings package, feature flag, and initial page.

View File

@ -0,0 +1,32 @@
{
"name": "woocommerce/settings-editor",
"description": "WooCommerce Admin settings page",
"type": "library",
"license": "GPL-3.0-or-later",
"minimum-stability": "dev",
"require-dev": {
"automattic/jetpack-changelogger": "3.3.0"
},
"config": {
"platform": {
"php": "7.4"
}
},
"extra": {
"changelogger": {
"formatter": {
"filename": "../../../tools/changelogger/class-package-formatter.php"
},
"types": {
"fix": "Fixes an existing bug",
"add": "Adds functionality",
"update": "Update existing functionality",
"dev": "Development related task",
"tweak": "A minor adjustment to the codebase",
"performance": "Address performance issues",
"enhancement": "Improve existing functionality"
},
"changelog": "CHANGELOG.md"
}
}
}

View File

@ -0,0 +1,7 @@
{
"rootDir": "./",
"roots": [
"<rootDir>/src"
],
"preset": "./node_modules/@woocommerce/internal-js-tests/jest-preset.js"
}

View File

@ -0,0 +1,254 @@
{
"name": "@woocommerce/settings-editor",
"version": "0.1.0",
"description": "React components for the WooCommerce admin settings editor.",
"author": "Automattic",
"license": "GPL-2.0-or-later",
"keywords": [
"wordpress",
"woocommerce"
],
"homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/settings-editor/README.md",
"repository": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce.git"
},
"bugs": {
"url": "https://github.com/woocommerce/woocommerce/issues"
},
"main": "build/index.js",
"module": "build-module/index.js",
"types": "build-types",
"react-native": "src/index",
"files": [
"build",
"build-module",
"build-style",
"build-types"
],
"sideEffects": [
"build-style/**",
"src/**/*.scss"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
"@types/lodash": "^4.14.202",
"@types/prop-types": "^15.7.11",
"@types/wordpress__blocks": "11.0.7",
"@woocommerce/settings": "^1.0.0",
"@woocommerce/tracks": "workspace:^",
"@wordpress/api-fetch": "wp-6.0",
"@wordpress/components": "wp-6.0",
"@wordpress/compose": "wp-6.0",
"@wordpress/core-data": "wp-6.0",
"@wordpress/data": "wp-6.0",
"@wordpress/dataviews": "^4.4.1",
"@wordpress/date": "wp-6.0",
"@wordpress/deprecated": "wp-6.0",
"@wordpress/edit-post": "wp-6.0",
"@wordpress/editor": "wp-6.0",
"@wordpress/element": "wp-6.0",
"@wordpress/hooks": "wp-6.0",
"@wordpress/html-entities": "wp-6.0",
"@wordpress/i18n": "wp-6.0",
"@wordpress/icons": "wp-6.0",
"@wordpress/interface": "wp-6.0",
"@wordpress/keyboard-shortcuts": "wp-6.0",
"@wordpress/keycodes": "wp-6.0",
"@wordpress/media-utils": "wp-6.0",
"@wordpress/plugins": "wp-6.0",
"@wordpress/preferences": "wp-6.0",
"@wordpress/private-apis": "^1.6.0",
"@wordpress/url": "wp-6.0",
"classnames": "^2.3.2",
"dompurify": "^2.4.7",
"prop-types": "^15.8.1",
"react-router-dom": "~6.3.0"
},
"devDependencies": {
"@babel/core": "^7.23.5",
"@babel/runtime": "^7.23.5",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.3",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/user-event": "13.5.0",
"@types/dompurify": "^2.4.0",
"@types/jest": "27.5.x",
"@types/react": "17.0.x",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/wordpress__block-editor": "7.0.0",
"@types/wordpress__block-library": "2.6.1",
"@types/wordpress__blocks": "11.0.7",
"@types/wordpress__components": "^19.10.5",
"@types/wordpress__core-data": "2.4.5",
"@types/wordpress__data": "6.0.2",
"@types/wordpress__date": "3.3.2",
"@types/wordpress__edit-post": "7.5.4",
"@types/wordpress__editor": "13.0.0",
"@types/wordpress__keycodes": "2.3.1",
"@types/wordpress__media-utils": "3.0.0",
"@types/wordpress__plugins": "3.0.0",
"@types/wordpress__rich-text": "3.4.6",
"@woocommerce/eslint-plugin": "workspace:*",
"@woocommerce/internal-js-tests": "workspace:*",
"@woocommerce/internal-style-build": "workspace:*",
"@wordpress/block-editor": "wp-6.0",
"@wordpress/browserslist-config": "wp-6.0",
"concurrently": "^7.6.0",
"copy-webpack-plugin": "^9.1.0",
"css-loader": "^3.6.0",
"eslint": "^8.55.0",
"jest": "27.5.x",
"jest-cli": "27.5.x",
"mini-css-extract-plugin": "^2.7.6",
"postcss": "^8.4.32",
"postcss-loader": "^4.3.0",
"react": "17.0.x",
"react-dom": "17.0.x",
"rimraf": "5.0.5",
"sass-loader": "^10.5.0",
"ts-jest": "29.1.x",
"typescript": "5.3.x",
"webpack": "^5.89.0",
"webpack-cli": "^3.3.12",
"webpack-remove-empty-scripts": "^0.7.3",
"webpack-rtl-plugin": "^2.0.0",
"wireit": "0.14.3"
},
"scripts": {
"build": "pnpm --if-present --workspace-concurrency=Infinity --stream --filter=\"$npm_package_name...\" '/^build:project:.*$/'",
"build:project": "pnpm --if-present '/^build:project:.*$/'",
"build:project:bundle": "wireit",
"build:project:cjs": "wireit",
"build:project:esm": "wireit",
"changelog": "XDEBUG_MODE=off composer install --quiet && composer exec -- changelogger",
"lint": "pnpm --if-present '/^lint:lang:.*$/'",
"lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
"lint:fix:lang:js": "eslint src --fix",
"lint:lang:js": "eslint src",
"prepack": "pnpm build",
"test:js": "jest --config ./jest.config.json --passWithNoTests",
"watch:build": "pnpm --if-present --workspace-concurrency=Infinity --filter=\"$npm_package_name...\" --parallel '/^watch:build:project:.*$/'",
"watch:build:project": "pnpm --if-present run '/^watch:build:project:.*$/'",
"watch:build:project:bundle": "wireit",
"watch:build:project:cjs": "wireit",
"watch:build:project:esm": "wireit"
},
"peerDependencies": {
"@types/react": "17.0.x",
"@wordpress/data": "wp-6.0",
"react": "17.0.x",
"react-dom": "17.0.x"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"webpack.config.js",
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js",
"events": [
"pull_request",
"push"
]
}
]
}
},
"wireit": {
"build:project:bundle": {
"command": "webpack",
"clean": "if-file-deleted",
"env": {
"NODE_ENV": {
"external": true,
"default": "production"
}
},
"files": [
"webpack.config.js",
"src/**/*.scss"
],
"output": [
"build-style"
],
"dependencies": [
"dependencyOutputs"
]
},
"watch:build:project:bundle": {
"command": "webpack --watch",
"service": true
},
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",
"clean": "if-file-deleted",
"files": [
"tsconfig-cjs.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"output": [
"build"
],
"dependencies": [
"dependencyOutputs"
]
},
"watch:build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json --watch",
"service": true
},
"build:project:esm": {
"command": "tsc --project tsconfig.json",
"clean": "if-file-deleted",
"files": [
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"output": [
"build-module",
"build-types"
],
"dependencies": [
"dependencyOutputs"
]
},
"watch:build:project:esm": {
"command": "tsc --project tsconfig.json --watch",
"service": true
},
"dependencyOutputs": {
"allowUsuallyExcludedPaths": true,
"files": [
"node_modules/@woocommerce/internal-style-build/index.js",
"node_modules/@woocommerce/internal-style-build/abstracts",
"node_modules/@woocommerce/internal-js-tests/build",
"node_modules/@woocommerce/internal-js-tests/build-module",
"node_modules/@woocommerce/internal-js-tests/jest-preset.js",
"node_modules/@woocommerce/eslint-plugin/configs",
"node_modules/@woocommerce/eslint-plugin/rules",
"node_modules/@woocommerce/eslint-plugin/index.js",
"node_modules/@woocommerce/tracks/build",
"node_modules/@woocommerce/tracks/build-module",
"node_modules/@woocommerce/tracks/build-types",
"package.json"
]
}
}
}

View File

@ -0,0 +1,8 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
export const SettingsEditor = () => {
return <div style={ { padding: '20px' } }>Settings Editor</div>;
};

View File

@ -0,0 +1,15 @@
{
"extends": "../tsconfig-cjs",
"compilerOptions": {
"outDir": "build",
"resolveJsonModule": true,
"typeRoots": [
"./typings",
"./node_modules/@types"
]
},
"include": [
"typings/**/*",
"src/**/*",
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"rootDir": "src",
"outDir": "build-module",
"declaration": true,
"declarationMap": true,
"declarationDir": "./build-types",
"resolveJsonModule": true,
"typeRoots": [
"./typings",
"./node_modules/@types"
]
},
"include": [
"typings/**/*",
"src/**/*",
],
}

View File

@ -0,0 +1,52 @@
/**
* External dependencies
*/
const RemoveEmptyScriptsPlugin = require( 'webpack-remove-empty-scripts' );
const WebpackRTLPlugin = require( 'webpack-rtl-plugin' );
/**
* Internal dependencies
*/
const {
webpackConfig,
plugin,
StyleAssetPlugin,
} = require( '@woocommerce/internal-style-build' );
const NODE_ENV = process.env.NODE_ENV || 'development';
module.exports = {
mode: process.env.NODE_ENV || 'development',
entry: {
'build-style': __dirname + '/src/style.scss',
},
output: {
path: __dirname,
},
module: {
parser: webpackConfig.parser,
rules: webpackConfig.rules,
},
plugins: [
new RemoveEmptyScriptsPlugin(),
new plugin( {
filename: ( data ) => {
return data.chunk.name.startsWith( '/build/blocks' )
? `[name].css`
: `[name]/style.css`;
},
chunkFilename: 'chunks/[id].style.css',
} ),
new WebpackRTLPlugin( {
test: /(?<!style)\.css$/,
filename: '[name]-rtl.css',
minify: NODE_ENV === 'development' ? false : { safe: true },
} ),
new WebpackRTLPlugin( {
test: /style\.css$/,
filename: '[name]/style-rtl.css',
minify: NODE_ENV === 'development' ? false : { safe: true },
} ),
new StyleAssetPlugin(),
],
};

View File

@ -33,17 +33,19 @@ export const useCheckoutSubmit = () => {
hasError: store.hasError(),
};
} );
const { activePaymentMethod, isExpressPaymentMethodActive } = useSelect(
( select ) => {
const store = select( PAYMENT_STORE_KEY );
const {
activePaymentMethod,
isExpressPaymentMethodActive,
isPaymentMethodsInitialized,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activePaymentMethod: store.getActivePaymentMethod(),
isExpressPaymentMethodActive:
store.isExpressPaymentMethodActive(),
};
}
);
return {
activePaymentMethod: store.getActivePaymentMethod(),
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
isPaymentMethodsInitialized: store.paymentMethodsInitialized(),
};
} );
const { onSubmit } = useCheckoutEventsContext();
@ -58,7 +60,10 @@ export const useCheckoutSubmit = () => {
paymentMethodButtonLabel,
onSubmit,
isCalculating,
isDisabled: isProcessing || isExpressPaymentMethodActive,
isDisabled:
isProcessing ||
isExpressPaymentMethodActive ||
! isPaymentMethodsInitialized,
waitingForProcessing,
waitingForRedirect,
};

View File

@ -11,6 +11,7 @@ import clsx from 'clsx';
import { RadioControlAccordion } from '@woocommerce/blocks-components';
import { useDispatch, useSelect } from '@wordpress/data';
import { getPaymentMethods } from '@woocommerce/blocks-registry';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
@ -28,7 +29,6 @@ const PaymentMethodOptions = () => {
const {
activeSavedToken,
activePaymentMethod,
isExpressPaymentMethodActive,
savedPaymentMethods,
availablePaymentMethods,
} = useSelect( ( select ) => {
@ -36,7 +36,6 @@ const PaymentMethodOptions = () => {
return {
activeSavedToken: store.getActiveSavedToken(),
activePaymentMethod: store.getActivePaymentMethod(),
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
savedPaymentMethods: store.getSavedPaymentMethods(),
availablePaymentMethods: store.getAvailablePaymentMethods(),
};
@ -62,7 +61,9 @@ const PaymentMethodOptions = () => {
} ),
name: `wc-saved-payment-method-token-${ name }`,
content: (
<PaymentMethodCard showSaveOption={ supports.showSaveOption }>
<PaymentMethodCard
showSaveOption={ !! supports.showSaveOption }
>
{ cloneElement( component, {
__internalSetActivePaymentMethod,
...paymentMethodInterface,
@ -94,7 +95,22 @@ const PaymentMethodOptions = () => {
const singleOptionClass = clsx( {
'disable-radio-control': isSinglePaymentMethod,
} );
return isExpressPaymentMethodActive ? null : (
const globalPaymentMethods = getSetting( 'globalPaymentMethods' );
if ( Object.keys( options ).length === 0 ) {
return (
<div
className="wc-payment-method-options-placeholder"
style={ {
minHeight:
Object.keys( globalPaymentMethods ).length * 3 + 'em',
} }
></div>
);
}
return (
<RadioControlAccordion
highlightChecked={ true }
id={ 'wc-payment-method-options' }

View File

@ -2,9 +2,10 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Label } from '@woocommerce/blocks-components';
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
import { Button } from '@ariakit/react';
/**
* Internal dependencies
@ -20,19 +21,31 @@ import './style.scss';
* @return {*} The rendered component.
*/
const PaymentMethods = () => {
const [ showPaymentMethodsToggle, setShowPaymentMethodsToggle ] =
useState( false );
const {
paymentMethodsInitialized,
availablePaymentMethods,
savedPaymentMethods,
hasSavedPaymentMethods,
isExpressPaymentMethodActive,
activeSavedToken,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
paymentMethodsInitialized: store.paymentMethodsInitialized(),
activeSavedToken: store.getActiveSavedToken(),
availablePaymentMethods: store.getAvailablePaymentMethods(),
savedPaymentMethods: store.getSavedPaymentMethods(),
hasSavedPaymentMethods:
Object.keys( store.getSavedPaymentMethods() || {} ).length > 0,
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
};
} );
// If using an express payment method, don't show the regular payment methods.
if ( isExpressPaymentMethodActive ) {
return null;
}
if (
paymentMethodsInitialized &&
Object.keys( availablePaymentMethods ).length === 0
@ -40,25 +53,43 @@ const PaymentMethods = () => {
return <NoPaymentMethods />;
}
// Show payment methods if the toggle is on or if there are no saved payment methods, or if the active saved token is not set.
const showPaymentMethods =
showPaymentMethodsToggle ||
! hasSavedPaymentMethods ||
( paymentMethodsInitialized && ! activeSavedToken );
return (
<>
<SavedPaymentMethodOptions />
{ Object.keys( savedPaymentMethods ).length > 0 && (
<Label
label={ __( 'Use another payment method.', 'woocommerce' ) }
screenReaderLabel={ __(
'Other available payment methods',
'woocommerce'
) }
wrapperElement="p"
wrapperProps={ {
className: [
'wc-block-components-checkout-step__description wc-block-components-checkout-step__description-payments-aligned',
],
} }
/>
{ hasSavedPaymentMethods && (
<>
<SavedPaymentMethodOptions />
<p className="wc-block-components-checkout-step__description wc-block-components-checkout-step__description-payments-aligned">
<Button
render={ <span /> }
type="button"
className="wc-block-components-show-payment-methods__link"
onClick={ ( e ) => {
e.preventDefault();
setShowPaymentMethodsToggle(
! showPaymentMethodsToggle
);
} }
aria-label={ __(
'Use another payment method',
'woocommerce'
) }
aria-expanded={ showPaymentMethodsToggle }
>
{ __(
'Use another payment method',
'woocommerce'
) }
</Button>
</p>
</>
) }
<PaymentMethodOptions />
{ showPaymentMethods && <PaymentMethodOptions /> }
</>
);
};

View File

@ -69,15 +69,20 @@ const getDefaultLabel = ( {
};
const SavedPaymentMethodOptions = () => {
const { activeSavedToken, activePaymentMethod, savedPaymentMethods } =
useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activeSavedToken: store.getActiveSavedToken(),
activePaymentMethod: store.getActivePaymentMethod(),
savedPaymentMethods: store.getSavedPaymentMethods(),
};
} );
const {
activeSavedToken,
activePaymentMethod,
savedPaymentMethods,
paymentMethodsInitialized,
} = useSelect( ( select ) => {
const store = select( PAYMENT_STORE_KEY );
return {
activeSavedToken: store.getActiveSavedToken(),
activePaymentMethod: store.getActivePaymentMethod(),
savedPaymentMethods: store.getSavedPaymentMethods(),
paymentMethodsInitialized: store.paymentMethodsInitialized(),
};
} );
const { __internalSetActivePaymentMethod } =
useDispatch( PAYMENT_STORE_KEY );
const canMakePaymentArg = getCanMakePaymentArg();
@ -153,6 +158,7 @@ const SavedPaymentMethodOptions = () => {
dispatchCheckoutEvent,
canMakePaymentArg,
] );
const savedPaymentMethodHandler =
!! activeSavedToken &&
paymentMethods[ activePaymentMethod ] &&
@ -167,12 +173,16 @@ const SavedPaymentMethodOptions = () => {
)
: null;
const selected = paymentMethodsInitialized
? activeSavedToken
: options[ 0 ].value;
return options.length > 0 ? (
<>
<RadioControl
highlightChecked={ true }
id={ 'wc-payment-method-saved-tokens' }
selected={ activeSavedToken }
selected={ selected }
options={ options }
onChange={ () => void 0 }
/>

View File

@ -149,6 +149,27 @@
pointer-events: all; // Overrides parent disabled component in editor context
}
.wc-payment-method-options-placeholder {
display: block;
height: 100px;
width: 100%;
@include placeholder();
}
.wc-block-components-show-payment-methods__link {
font-weight: normal;
background: none;
border: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
text-align: left;
&[aria-expanded="true"] {
text-decoration: none;
}
}
.is-mobile,
.is-small {
.wc-block-card-elements {

View File

@ -173,13 +173,6 @@ describe( 'PaymentMethods', () => {
</>
);
await waitFor( () => {
const savedPaymentMethodOptions = screen.queryByText(
/Saved payment method options/
);
expect( savedPaymentMethodOptions ).not.toBeNull();
} );
await waitFor( () => {
const paymentMethodOptions = screen.queryByText(
/Payment method options/

View File

@ -5,6 +5,8 @@ import {
CanMakePaymentArgument,
ExpressPaymentMethodConfigInstance,
PaymentMethodConfigInstance,
PlainExpressPaymentMethods,
PlainPaymentMethods,
} from '@woocommerce/types';
import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings';
import { dispatch, select } from '@wordpress/data';
@ -151,8 +153,23 @@ const registrationErrorNotice = (
} );
};
const compareAvailablePaymentMethods = (
paymentMethods: PlainPaymentMethods | PlainExpressPaymentMethods,
availablePaymentMethods: PlainPaymentMethods | PlainExpressPaymentMethods
) => {
const compareKeys1 = Object.keys( paymentMethods );
const compareKeys2 = Object.keys( availablePaymentMethods );
return (
compareKeys1.length === compareKeys2.length &&
compareKeys1.every( ( current ) => compareKeys2.includes( current ) )
);
};
export const checkPaymentMethodsCanPay = async ( express = false ) => {
let availablePaymentMethods = {};
const availablePaymentMethods:
| PlainPaymentMethods
| PlainExpressPaymentMethods = {};
const paymentMethods = express
? getExpressPaymentMethods()
@ -167,30 +184,24 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
const { name, title, description, gatewayId, supports } =
paymentMethod as ExpressPaymentMethodConfigInstance;
availablePaymentMethods = {
...availablePaymentMethods,
[ paymentMethod.name ]: {
name,
title,
description,
gatewayId,
supportsStyle: supports?.style,
},
availablePaymentMethods[ name ] = {
name,
title,
description,
gatewayId,
supportsStyle: supports?.style || [],
};
} else {
const { name } = paymentMethod as PaymentMethodConfigInstance;
availablePaymentMethods = {
...availablePaymentMethods,
[ paymentMethod.name ]: {
name,
},
availablePaymentMethods[ name ] = {
name,
};
}
};
// Order payment methods.
const paymentMethodsOrder = express
const sortedPaymentMethods = express
? Object.keys( paymentMethods )
: Array.from(
new Set( [
@ -202,9 +213,9 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
const cartPaymentMethods = canPayArgument.paymentMethods as string[];
const isEditor = !! select( 'core/editor' );
for ( let i = 0; i < paymentMethodsOrder.length; i++ ) {
const paymentMethodName = paymentMethodsOrder[ i ];
const paymentMethod = paymentMethods[ paymentMethodName ];
for ( let i = 0; i < sortedPaymentMethods.length; i++ ) {
const paymentMethodName = sortedPaymentMethods[ i ];
const paymentMethod = paymentMethods[ paymentMethodName ] || {};
if ( ! paymentMethod ) {
continue;
@ -224,7 +235,7 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
) );
if ( canPay ) {
if ( typeof canPay === 'object' && canPay.error ) {
if ( typeof canPay === 'object' && canPay?.error ) {
throw new Error( canPay.error.message );
}
addAvailablePaymentMethod( paymentMethod );
@ -236,31 +247,31 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => {
}
}
const availablePaymentMethodNames = Object.keys( availablePaymentMethods );
const currentlyAvailablePaymentMethods = express
const currentPaymentMethods = express
? select( PAYMENT_STORE_KEY ).getAvailableExpressPaymentMethods()
: select( PAYMENT_STORE_KEY ).getAvailablePaymentMethods();
if (
Object.keys( currentlyAvailablePaymentMethods ).length ===
availablePaymentMethodNames.length &&
Object.keys( currentlyAvailablePaymentMethods ).every( ( current ) =>
availablePaymentMethodNames.includes( current )
! compareAvailablePaymentMethods(
availablePaymentMethods,
currentPaymentMethods
)
) {
// All the names are the same, no need to dispatch more actions.
return true;
const {
__internalSetAvailablePaymentMethods,
__internalSetAvailableExpressPaymentMethods,
} = dispatch( PAYMENT_STORE_KEY );
if ( express ) {
__internalSetAvailableExpressPaymentMethods(
availablePaymentMethods as PlainExpressPaymentMethods
);
} else {
__internalSetAvailablePaymentMethods(
availablePaymentMethods as PlainPaymentMethods
);
}
}
const {
__internalSetAvailablePaymentMethods,
__internalSetAvailableExpressPaymentMethods,
} = dispatch( PAYMENT_STORE_KEY );
const setCallback = express
? __internalSetAvailableExpressPaymentMethods
: __internalSetAvailablePaymentMethods;
setCallback( availablePaymentMethods );
return true;
};

View File

@ -70,7 +70,7 @@ export const previewShippingRates: CartResponseShippingRate[] = [
},
{
...API_SITE_CURRENCY,
name: __( 'Local pickup', 'woocommerce' ),
name: __( 'Local pickup #1', 'woocommerce' ),
description: '',
delivery_time: '',
price: '0',
@ -92,7 +92,7 @@ export const previewShippingRates: CartResponseShippingRate[] = [
},
{
...API_SITE_CURRENCY,
name: __( 'Local pickup', 'woocommerce' ),
name: __( 'Local pickup #2', 'woocommerce' ),
description: '',
delivery_time: '',
price: '0',

View File

@ -150,7 +150,12 @@ export type PaymentMethods =
/**
* Used to represent payment methods in a context where storing objects is not allowed, i.e. in data stores.
*/
export type PlainPaymentMethods = Record<
export type PlainPaymentMethods = Record< string, { name: string } >;
/**
* Used to represent payment methods in a context where storing objects is not allowed, i.e. in data stores.
*/
export type PlainExpressPaymentMethods = Record<
string,
{
name: string;
@ -161,11 +166,6 @@ export type PlainPaymentMethods = Record<
}
>;
/**
* Used to represent payment methods in a context where storing objects is not allowed, i.e. in data stores.
*/
export type PlainExpressPaymentMethods = PlainPaymentMethods;
export type ExpressPaymentMethods =
| Record< string, ExpressPaymentMethodConfigInstance >
| EmptyObjectType;

View File

@ -0,0 +1,4 @@
Significance: minor
Type: tweak
Tweak: removed the Los Angeles Local Pickup mock option in the editor preview for Cart and Checkout.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Settings package, feature flag, and initial page.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Make sure session exists before calling its functions in the Cart StoreApi route

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Improve remote logging user data privacy

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add a method to the order object to get info about the card used for payment

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Lower opacity for payments section to work across light and dark backgrounds

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Experimental: Respect empty filter options setting in the Attribute filter block.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add the Cost of Goods Sold related code and REST APIs for the order and order item classes

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Add loading placeholder and payment method toggle to block checkout

View File

@ -65,9 +65,6 @@ const CoreProfiler = lazy( () =>
import( /* webpackChunkName: "core-profiler" */ '../core-profiler' )
);
const SettingsGroup = lazy( () =>
import( /* webpackChunkName: "settings" */ '../settings' )
);
const WCPaymentsWelcomePage = lazy( () =>
import(
/* webpackChunkName: "wcpay-payment-welcome-page" */ '../payments-welcome'
@ -321,35 +318,6 @@ export const getPages = () => {
} );
}
if ( window.wcAdminFeatures.settings ) {
pages.push( {
container: SettingsGroup,
path: '/settings/:page',
breadcrumbs: ( { match } ) => {
// @todo This might need to be refactored to retrieve groups via data store.
const settingsPages = getAdminSetting( 'settingsPages' );
const page = settingsPages[ match.params.page ];
if ( ! page ) {
return [];
}
return [
...initialBreadcrumbs,
[
settingsPages.general
? '/settings/general'
: `/settings/${
Object.keys( settingsPages )[ 0 ]
}`,
__( 'Settings', 'woocommerce' ),
],
page,
];
},
wpOpenMenu: 'toplevel_page_woocommerce',
capability: 'manage_woocommerce',
} );
}
if ( window.wcAdminFeatures[ 'wc-pay-welcome-page' ] ) {
pages.push( {
container: WCPaymentsWelcomePage,

View File

@ -1,3 +1,11 @@
export default ( {} ) => {
return <div>Settings page</div>;
};
/**
* External dependencies
*/
import { createRoot } from '@wordpress/element';
import { SettingsEditor } from '@woocommerce/settings-editor';
const node = document.getElementById( 'wc-settings-page' );
if ( node ) {
createRoot( node ).render( <SettingsEditor /> );
}

View File

@ -163,6 +163,7 @@
"@woocommerce/number": "workspace:*",
"@woocommerce/onboarding": "workspace:*",
"@woocommerce/product-editor": "workspace:*",
"@woocommerce/settings-editor": "workspace:*",
"@woocommerce/remote-logging": "workspace:*",
"@woocommerce/tracks": "workspace:*",
"@wordpress/babel-preset-default": "^6.17.0",
@ -308,6 +309,10 @@
"node_modules/@woocommerce/remote-logging/build",
"node_modules/@woocommerce/remote-logging/build-module",
"node_modules/@woocommerce/remote-logging/build-types",
"node_modules/@woocommerce/settings-editor/build",
"node_modules/@woocommerce/settings-editor/build-module",
"node_modules/@woocommerce/settings-editor/build-style",
"node_modules/@woocommerce/settings-editor/build-types",
"node_modules/@woocommerce/product-editor/build",
"node_modules/@woocommerce/product-editor/build-module",
"node_modules/@woocommerce/product-editor/build-style",

View File

@ -59,12 +59,14 @@ const wcAdminPackages = [
'onboarding',
'block-templates',
'product-editor',
'settings-editor',
'remote-logging',
];
const getEntryPoints = () => {
const entryPoints = {
app: './client/index.js',
settings: './client/settings/index.js',
};
wcAdminPackages.forEach( ( name ) => {
entryPoints[ name ] = `${ WC_ADMIN_PACKAGES_DIR }/${ name }`;
@ -92,7 +94,8 @@ const webpackConfig = {
filename: ( data ) => {
// Output wpAdminScripts to wp-admin-scripts folder
// See https://github.com/woocommerce/woocommerce-admin/pull/3061
return wpAdminScripts.includes( data.chunk.name )
return wpAdminScripts.includes( data.chunk.name ) ||
data.chunk.name === 'settings'
? `wp-admin-scripts/[name]${ outputSuffix }.js`
: `[name]/index${ outputSuffix }.js`;
},

View File

@ -2103,14 +2103,14 @@ p.demo_store,
}
#payment {
background: $secondary;
background: desaturate(rgba($primary, .14), 21%);
border-radius: 5px;
ul.payment_methods {
@include clearfix();
text-align: left;
padding: 1em;
border-bottom: 1px solid darken($secondary, 10%);
border-bottom: 1px solid darken(desaturate(rgba($primary, .14), 21%), 10%);
margin: 0;
list-style: none outside;

View File

@ -11,6 +11,8 @@
*/
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\Orders\PaymentInfo;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\NumberUtil;
@ -25,6 +27,7 @@ require_once WC_ABSPATH . 'includes/legacy/abstract-wc-legacy-order.php';
*/
abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
use WC_Item_Totals;
use CogsAwareTrait;
/**
* Order Data array. This is the core order data exposed in APIs since 3.0.0.
@ -112,6 +115,10 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* @param int|object|WC_Order $order Order to read.
*/
public function __construct( $order = 0 ) {
if ( $this->has_cogs() && $this->cogs_is_enabled() ) {
$this->data['cogs_total_value'] = 0;
}
parent::__construct( $order );
if ( is_numeric( $order ) && $order > 0 ) {
@ -592,6 +599,15 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
);
}
/**
* Get info about the card used for payment in the order.
*
* @return array
*/
public function get_payment_card_info() {
return PaymentInfo::get_card_info( $this );
}
/*
|--------------------------------------------------------------------------
| Setters
@ -852,17 +868,24 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
protected function type_to_group( $type ) {
$type_to_group = apply_filters(
'woocommerce_order_type_to_group',
array(
'line_item' => 'line_items',
'tax' => 'tax_lines',
'shipping' => 'shipping_lines',
'fee' => 'fee_lines',
'coupon' => 'coupon_lines',
)
$this->item_types_to_group
);
return isset( $type_to_group[ $type ] ) ? $type_to_group[ $type ] : '';
return $type_to_group[ $type ] ?? '';
}
/**
* Mappings of order item types to groups.
*
* @var array
*/
protected array $item_types_to_group = array(
'line_item' => 'line_items',
'tax' => 'tax_lines',
'shipping' => 'shipping_lines',
'fee' => 'fee_lines',
'coupon' => 'coupon_lines',
);
/**
* Return an array of items/products within this order.
*
@ -1807,7 +1830,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
public function get_total_fees() {
return array_reduce(
$this->get_fees(),
function( $carry, $item ) {
function ( $carry, $item ) {
return $carry + (float) $item->get_total();
},
0.0
@ -1964,6 +1987,10 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->set_discount_tax( wc_round_tax_total( $cart_subtotal_tax - $cart_total_tax ) );
$this->set_total( NumberUtil::round( $cart_total + $fees_total + (float) $this->get_shipping_total() + (float) $this->get_cart_tax() + (float) $this->get_shipping_tax(), wc_get_price_decimals() ) );
if ( $this->has_cogs() && $this->cogs_is_enabled() ) {
$this->calculate_cogs_total_value();
}
do_action( 'woocommerce_order_after_calculate_totals', $and_taxes, $this );
$this->save();
@ -2420,11 +2447,104 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*
* @return string Order title.
*/
public function get_title() : string {
public function get_title(): string {
if ( method_exists( $this->data_store, 'get_title' ) ) {
return $this->data_store->get_title( $this );
} else {
return __( 'Order', 'woocommerce' );
}
}
/**
* Indicates if the current order has an associated Cost of Goods Sold value.
*
* Derived classes representing orders that have a COGS value should override this method to return "true".
*
* @since 9.5.0
*
* @return bool True if this order has an associated Cost of Goods Sold value.
*/
public function has_cogs() {
return false;
}
/**
* Calculate the Cost of Goods Sold value and set it as the actual value for this order.
*
* @since 9.5.0
*
* @return float The calculated value.
*/
public function calculate_cogs_total_value(): float {
if ( ! $this->has_cogs() || ! $this->cogs_is_enabled( __METHOD__ ) ) {
return 0;
}
$cogs_value = $this->calculate_cogs_total_value_core();
/**
* Filter to modify the Cost of Goods Sold value that gets calculated for a given order.
*
* @since 9.5.0
*
* @param float $value The value originally calculated.
* @param WC_Abstract_Order $order The order for which the value is calculated.
*/
$cogs_value = apply_filters( 'woocommerce_calculated_order_cogs_value', $cogs_value, $this );
$this->set_cogs_total_value( $cogs_value );
return $cogs_value;
}
/**
* Core method to calculate the Cost of Goods Sold value for this order:
* it doesn't check if COGS is enabled at class or system level, doesn't fire hooks, and doesn't set the value as the current one for the order.
*
* @return float The calculated value.
*/
protected function calculate_cogs_total_value_core(): float {
if ( ! $this->has_cogs() || ! $this->cogs_is_enabled( __METHOD__ ) ) {
return 0;
}
$value = 0;
foreach ( array_keys( $this->item_types_to_group ) as $item_type ) {
$order_items = $this->get_items( $item_type );
foreach ( $order_items as $item ) {
if ( $item->has_cogs() ) {
$item->calculate_cogs_value();
$value += $item->get_cogs_value();
}
}
}
return $value;
}
/**
* Get the value of the Cost of Goods Sold for this order.
*
* WARNING! If the Cost of Goods Sold feature is disabled this method will always return zero.
*
* @return float The current value for this order.
*/
public function get_cogs_total_value(): float {
return (float) ( $this->has_cogs() && $this->cogs_is_enabled( __METHOD__ ) ? $this->get_prop( 'cogs_total_value' ) : 0 );
}
/**
* Set the value of the Cost of Goods Sold for this order.
*
* WARNING! If the Cost of Goods Sold feature is disabled this method will have no effect.
*
* @param float $value The value to set for this order.
*
* @internal This method is intended for data store usage only, the value set here will be overridden by calculate_cogs_total_value.
*/
public function set_cogs_total_value( float $value ) {
if ( $this->has_cogs() && $this->cogs_is_enabled( __METHOD__ ) ) {
$this->set_prop( 'cogs_total_value', $value );
}
}
}

View File

@ -2209,7 +2209,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @param float $value The value to set for this product.
*/
public function set_cogs_value( float $value ): void {
if ( $this->cogs_is_enabled( __CLASS__ . '::' . __METHOD__ ) ) {
if ( $this->cogs_is_enabled( __METHOD__ ) ) {
$this->set_prop( 'cogs_value', $value );
}
}
@ -2222,7 +2222,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return float The current value for this product.
*/
public function get_cogs_value(): float {
return $this->cogs_is_enabled( __CLASS__ . '::' . __METHOD__ ) ? (float) $this->get_prop( 'cogs_value' ) : 0;
return $this->cogs_is_enabled( __METHOD__ ) ? (float) $this->get_prop( 'cogs_value' ) : 0;
}
/**
@ -2234,7 +2234,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return float The effective value for this product.
*/
public function get_cogs_effective_value(): float {
return $this->cogs_is_enabled( __CLASS__ . '::' . __METHOD__ ) ? $this->get_cogs_effective_value_core() : 0;
return $this->cogs_is_enabled( __METHOD__ ) ? $this->get_cogs_effective_value_core() : 0;
}
/**
@ -2260,7 +2260,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return float The effective total value for this product.
*/
public function get_cogs_total_value(): float {
if ( ! $this->cogs_is_enabled( __CLASS__ . '::' . __METHOD__ ) ) {
if ( ! $this->cogs_is_enabled( __METHOD__ ) ) {
return 0;
}
@ -2272,7 +2272,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @param float $total_value The effective total value of the product.
* @param WC_Product $product The product for which the total value is being retrieved.
*/
return apply_filters( 'woocommerce_get_cogs_total_value', $this->get_cogs_total_value_core(), $this );
return apply_filters( 'woocommerce_get_product_cogs_total_value', $this->get_cogs_total_value_core(), $this );
}
/**

View File

@ -120,7 +120,14 @@ class WC_Admin_Menus {
* Add menu item.
*/
public function settings_menu() {
$settings_page = add_submenu_page( 'woocommerce', __( 'WooCommerce settings', 'woocommerce' ), __( 'Settings', 'woocommerce' ), 'manage_woocommerce', 'wc-settings', array( $this, 'settings_page' ) );
$settings_page = add_submenu_page(
'woocommerce',
__( 'WooCommerce settings', 'woocommerce' ),
__( 'Settings', 'woocommerce' ),
'manage_woocommerce',
'wc-settings',
array( $this, 'settings_page' )
);
add_action( 'load-' . $settings_page, array( $this, 'settings_page_init' ) );
}
@ -347,7 +354,12 @@ class WC_Admin_Menus {
* Init the settings page.
*/
public function settings_page() {
WC_Admin_Settings::output();
if ( Features::is_enabled( 'settings' ) ) {
echo '<div style="padding: 20px;">The current screen is: ' . esc_html( get_current_screen()->id ) . '</div>';
echo '<div id="wc-settings-page"/>';
} else {
WC_Admin_Settings::output();
}
}
/**

View File

@ -8,12 +8,15 @@
* @version 3.4.0
*/
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
defined( 'ABSPATH' ) || exit;
/**
* Checkout class.
*/
class WC_Checkout {
use CogsAwareTrait;
/**
* The single instance of the class.
@ -451,6 +454,10 @@ class WC_Checkout {
$order->set_payment_method( isset( $available_gateways[ $data['payment_method'] ] ) ? $available_gateways[ $data['payment_method'] ] : $data['payment_method'] );
$this->set_data_from_cart( $order );
if ( $this->cogs_is_enabled() ) {
$order->calculate_cogs_total_value();
}
/**
* Action hook to adjust order before save.
*

View File

@ -505,4 +505,29 @@ class WC_Order_Item_Product extends WC_Order_Item {
}
return parent::offsetExists( $offset );
}
/**
* Indicates that product line items have an associated Cost of Goods Sold value.
* Note that this is true even if the product has np COGS value (in that case the COGS value for the line item will be zero)-
*
* @return bool Always true.
*/
public function has_cogs(): bool {
return true;
}
/**
* Calculate the Cost of Goods Sold value for this line item.
*
* @return float|null The calculated value, null if the product associated to the line item no longer exists.
*/
public function calculate_cogs_value_core(): ?float {
$product = $this->get_product();
if ( ! $product ) {
return null;
}
$cogs_per_unit = $product->get_cogs_total_value();
return $cogs_per_unit * $this->get_quantity();
}
}

View File

@ -10,12 +10,16 @@
* @since 3.0.0
*/
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
defined( 'ABSPATH' ) || exit;
/**
* Order item class.
*/
class WC_Order_Item extends WC_Data implements ArrayAccess {
use CogsAwareTrait;
/**
* Legacy cart item values.
*
@ -81,6 +85,10 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
* @param int|object|array $item ID to load from the DB, or WC_Order_Item object.
*/
public function __construct( $item = 0 ) {
if ( $this->has_cogs() && $this->cogs_is_enabled() ) {
$this->data['cogs_value'] = null;
}
parent::__construct( $item );
if ( $item instanceof WC_Order_Item ) {
@ -107,13 +115,7 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
* @since 3.2.0
*/
public function apply_changes() {
if ( function_exists( 'array_replace' ) ) {
$this->data = array_replace( $this->data, $this->changes ); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.array_replaceFound
} else { // PHP 5.2 compatibility.
foreach ( $this->changes as $key => $change ) {
$this->data[ $key ] = $change;
}
}
$this->data = array_replace( $this->data, $this->changes );
$this->changes = array();
}
@ -444,4 +446,102 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
return null;
}
/**
* Indicates if the current order item has an associated Cost of Goods Sold value.
*
* Derived classes representing line items that have a COGS value
* should override this method to return "true" and also the 'calculate_cogs_value_core' method.
*
* @since 9.5.0
*
* @return bool True if this line item has an associated Cost of Goods Sold value.
*/
public function has_cogs(): bool {
return false;
}
/**
* Calculate the Cost of Goods Sold value and set it as the actual value for this line item.
*
* @since 9.5.0
*
* @return bool True if the value has been calculated successfully (and set as the actual value), false otherwise (and the value hasn't changed).
* @throws Exception The class doesn't implement its own version of calculate_cogs_value_core. Derived classes are expected to override that method when has_cogs returns true.
*/
public function calculate_cogs_value(): bool {
if ( ! $this->has_cogs() || ! $this->cogs_is_enabled( __METHOD__ ) ) {
return false;
}
$value = $this->calculate_cogs_value_core();
/**
* Filter to modify the Cost of Goods Sold value that gets calculated for a given order item.
*
* @since 9.5.0
*
* @param float|null $value The value originally calculated, null if it was not possible to calculate it.
* @param WC_Order_Item $line_item The order item for which the value is calculated.
*/
$value = apply_filters( 'woocommerce_calculated_order_item_cogs_value', $value, $this );
if ( is_null( $value ) ) {
return false;
}
$this->set_cogs_value( (float) $value );
return true;
}
// phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn
/**
* Core method to calculate the Cost of Goods Sold value for this line item:
* it doesn't check if COGS is enabled at class or system level, doesn't fire hooks, and doesn't set the value as the current one for the line item.
*
* @return float|null The calculated value, or null if the value can't be calculated for some reason.
* @throws Exception The class doesn't implement its own version of this method. Derived classes are expected to override this method when has_cogs returns true.
*/
protected function calculate_cogs_value_core(): ?float {
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new Exception(
sprintf(
// translators: %1$s = class and method name.
__( 'Method %1$s is not implemented. Classes overriding has_cogs must override this method too.', 'woocommerce' ),
__METHOD__
)
);
// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn
/**
* Get the value of the Cost of Goods Sold for this order item.
*
* WARNING! If the Cost of Goods Sold feature is disabled this method will always return zero.
*
* @param string $context What the value is for. Valid values are view and edit.
* @return float The current value for this order item.
*/
public function get_cogs_value( $context = 'view' ): float {
return (float) ( $this->has_cogs() && $this->cogs_is_enabled( __METHOD__ ) ? $this->get_prop( 'cogs_value', $context ) : 0 );
}
/**
* Set the value of the Cost of Goods Sold for this order item.
* Usually you'll want to use calculate_cogs_value instead.
*
* WARNING! If the Cost of Goods Sold feature is disabled this method will have no effect.
*
* @param float $value The value to set for this order item.
*
* @internal This method is intended for data store usage only, the value set here will be overridden by calculate_cogs_value.
*/
public function set_cogs_value( float $value ): void {
if ( $this->has_cogs() && $this->cogs_is_enabled( __METHOD__ ) ) {
$this->set_prop( 'cogs_value', $value );
}
}
}

View File

@ -2357,4 +2357,14 @@ class WC_Order extends WC_Abstract_Order {
public function untrash(): bool {
return (bool) $this->data_store->untrash_order( $this );
}
/**
* Indicates that regular orders have an associated Cost of Goods Sold value.
* Note that this is true even if the order has no line items with COGS values (in that case the COGS value for the order will be zero)-
*
* @return bool Always true.
*/
public function has_cogs() {
return true;
}
}

View File

@ -5,6 +5,8 @@
* @package WooCommerce\DataStores
*/
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@ -16,6 +18,8 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP implements WC_Object_Data_Store_Interface {
use CogsAwareTrait;
/**
* Meta type. This should match up with
* the types available at https://developer.wordpress.org/reference/functions/add_metadata/.
@ -34,11 +38,35 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
*/
protected $object_id_field_for_meta = 'order_item_id';
/**
* Indicates if the Cost of Goods Sold feature is enabled.
*
* @var bool
*/
private bool $cogs_is_enabled;
/**
* The instance of WC_Order_Item_Data_Store to use for COGS related operations.
*
* @var WC_Order_Item_Data_Store
*/
private WC_Data_Store $order_item_data_store;
/**
* Class constructor.
*/
public function __construct() {
$this->cogs_is_enabled = $this->cogs_is_enabled();
if ( $this->cogs_is_enabled ) {
$this->order_item_data_store = WC_Data_Store::load( 'order-item' );
}
}
/**
* Create a new order item in the database.
*
* @since 3.0.0
* @param WC_Order_Item $item Order item object.
* @since 3.0.0
*/
public function create( &$item ) {
global $wpdb;
@ -54,6 +82,11 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
$item->set_id( $wpdb->insert_id );
$this->save_item_data( $item );
$item->save_meta_data();
if ( $this->cogs_is_enabled && $item->has_cogs() ) {
$this->save_cogs_data( $item );
}
$item->apply_changes();
$this->clear_cache( $item );
@ -63,8 +96,8 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
/**
* Update a order item in the database.
*
* @since 3.0.0
* @param WC_Order_Item $item Order item object.
* @since 3.0.0
*/
public function update( &$item ) {
global $wpdb;
@ -85,6 +118,9 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
$this->save_item_data( $item );
$item->save_meta_data();
if ( $this->cogs_is_enabled && $item->has_cogs() ) {
$this->save_cogs_data( $item );
}
$item->apply_changes();
$this->clear_cache( $item );
@ -94,9 +130,9 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
/**
* Remove an order item from the database.
*
* @since 3.0.0
* @param WC_Order_Item $item Order item object.
* @param array $args Array of args to pass to the delete method.
* @since 3.0.0
*/
public function delete( &$item, $args = array() ) {
if ( $item->get_id() ) {
@ -112,11 +148,10 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
/**
* Read a order item from the database.
*
* @since 3.0.0
*
* @param WC_Order_Item $item Order item object.
*
* @throws Exception If invalid order item.
* @since 3.0.0
*/
public function read( &$item ) {
global $wpdb;
@ -142,16 +177,33 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
)
);
$item->read_meta_data();
if ( $this->cogs_is_enabled && $item->has_cogs() ) {
$cogs_value = (float) $this->order_item_data_store->get_metadata( $item->get_id(), '_cogs_value', true );
/**
* Filter to customize the Cost of Goods Sold value that gets loaded for a given order item.
*
* @since 9.5.0
*
* @param float $cogs_value The value as read from the database.
* @param WC_Order_Item $product The order item for which the value is being loaded.
*/
$cogs_value = apply_filters( 'woocommerce_load_order_item_cogs_value', $cogs_value, $item );
$item->set_cogs_value( (float) $cogs_value );
}
}
/**
* Saves an item's data to the database / item meta.
* Ran after both create and update, so $item->get_id() will be set.
*
* @since 3.0.0
* @param WC_Order_Item $item Order item object.
* @since 3.0.0
*/
public function save_item_data( &$item ) {}
public function save_item_data( &$item ) {
}
/**
* Clear meta cache.
@ -163,4 +215,34 @@ abstract class Abstract_WC_Order_Item_Type_Data_Store extends WC_Data_Store_WP i
wp_cache_delete( 'order-items-' . $item->get_order_id(), 'orders' );
wp_cache_delete( $item->get_id(), $this->meta_type . '_meta' );
}
/**
* Persist the Cost of Goods Sold related data to the database.
*
* @param WC_Order_Item $item The order item for which the data will be persisted.
*/
private function save_cogs_data( WC_Order_Item $item ) {
$cogs_value = $item->get_cogs_value();
/**
* Filter to customize the Cost of Goods Sold value that gets saved for a given order item,
* or to suppress the saving of the value (so that custom storage can be used).
*
* @since 9.5.0
*
* @param float|null $cogs_value The value to be written to the database. If returned as null, nothing will be written.
* @param WC_Order_Item $item The order item for which the value is being saved.
*/
$cogs_value = apply_filters( 'woocommerce_save_order_item_cogs_value', $cogs_value, $item );
if ( is_null( $cogs_value ) ) {
return;
}
$cogs_value = (float) $cogs_value;
if ( 0.0 === $cogs_value ) {
$this->order_item_data_store->delete_metadata( $item->get_id(), '_cogs_value', '', true );
} else {
$this->order_item_data_store->update_metadata( $item->get_id(), '_cogs_value', $cogs_value );
}
}
}

View File

@ -499,7 +499,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
* @param float $cogs_value The value as read from the database.
* @param WC_Product $product The product for which the value is being loaded.
*/
$cogs_value = apply_filters( 'woocommerce_load_cogs_value', $cogs_value, $product );
$cogs_value = apply_filters( 'woocommerce_load_product_cogs_value', $cogs_value, $product );
$product->set_props( array( 'cogs_value' => $cogs_value ) );
}
@ -749,7 +749,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
* @param float|null $cogs_value The value to be written to the database. If returned as null, nothing will be written.
* @param WC_Product $product The product for which the value is being saved.
*/
$cogs_value = apply_filters( 'woocommerce_save_cogs_value', $cogs_value, $product );
$cogs_value = apply_filters( 'woocommerce_save_product_cogs_value', $cogs_value, $product );
if ( ! is_null( $cogs_value ) ) {
$updated = $this->update_or_delete_post_meta( $product, '_cogs_total_value', 0.0 === $cogs_value ? '' : $cogs_value );

View File

@ -460,7 +460,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
* @param bool $cogs_value_overrides_parent The flag as read from the database.
* @param WC_Product $product The product for which the flag is being loaded.
*/
$cogs_value_overrides_parent = apply_filters( 'woocommerce_load_cogs_overrides_parent_value_flag', $cogs_value_overrides_parent, $product );
$cogs_value_overrides_parent = apply_filters( 'woocommerce_load_product_cogs_overrides_parent_value_flag', $cogs_value_overrides_parent, $product );
$product->set_props(
array(
@ -578,7 +578,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
* @param bool|null $cogs_value_overrides_parent The flag to be written to the database. If null is returned nothing will be written or deleted.
* @param WC_Product $product The product for which the flag is being saved.
*/
$cogs_value_overrides_parent = apply_filters( 'woocommerce_save_cogs_overrides_parent_value_flag', $cogs_value_overrides_parent, $product );
$cogs_value_overrides_parent = apply_filters( 'woocommerce_save_product_cogs_overrides_parent_value_flag', $cogs_value_overrides_parent, $product );
if ( ! is_null( $cogs_value_overrides_parent ) ) {
$updated = $this->update_or_delete_post_meta( $product, '_cogs_value_overrides_parent', $cogs_value_overrides_parent ? 'yes' : '' );

View File

@ -276,7 +276,7 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
$order_item_name = $data['name'];
$data['meta_data'] = array_filter(
$data['meta_data'],
function( $meta ) use ( $product, $order_item_name ) {
function ( $meta ) use ( $product, $order_item_name ) {
$display_value = wp_kses_post( rawurldecode( (string) $meta->value ) );
// Skip items with values already in the product details area of the product name.
@ -537,13 +537,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
* @return WP_REST_Response
*/
public function prepare_object_for_response( $object, $request ) {
$this->request = $request;
$this->request['dp'] = is_null( $this->request['dp'] ) ? wc_get_price_decimals() : absint( $this->request['dp'] );
$request['context'] = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->get_formatted_item_data( $object );
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $request['context'] );
$response = rest_ensure_response( $data );
$data = $this->prepare_object_for_response_core( $object, $request );
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $object, $request ) );
/**
@ -561,6 +556,26 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
return apply_filters( "woocommerce_rest_prepare_{$this->post_type}_object", $response, $object, $request );
}
/**
* Core method to prepare a single order object for response
* (doesn't fire hooks, execute rest_ensure_response, or add links).
*
* @param WC_Data $order Object data.
* @param WP_REST_Request $request Request object.
* @return array Prepared response data.
* @since 9.5.0
*/
protected function prepare_object_for_response_core( $order, $request ): array {
$this->request = $request;
$this->request['dp'] = is_null( $this->request['dp'] ) ? wc_get_price_decimals() : absint( $this->request['dp'] );
$request['context'] = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->get_formatted_item_data( $order );
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $request['context'] );
return $data;
}
/**
* Prepare links for the request.
*

View File

@ -8,8 +8,8 @@
* @since 2.6.0
*/
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
defined( 'ABSPATH' ) || exit;
@ -21,6 +21,7 @@ defined( 'ABSPATH' ) || exit;
* @extends WC_REST_Orders_V2_Controller
*/
class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
use CogsAwareTrait;
/**
* Endpoint namespace.
@ -49,7 +50,7 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
$current_order_coupons = array_values( $order->get_coupons() );
$current_order_coupon_codes = array_map(
function( $coupon ) {
function ( $coupon ) {
return $coupon->get_code();
},
$current_order_coupons
@ -170,6 +171,30 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $order, $request, $creating );
}
/**
* Create or update a line item, overridden to add COGS data as needed.
*
* @param array $posted Line item data.
* @param string $action 'create' to add line item or 'update' to update it.
* @param object $item Passed when updating an item. Null during creation.
* @return WC_Order_Item_Product
* @throws WC_REST_Exception Invalid data, server error.
*/
protected function prepare_line_items( $posted, $action = 'create', $item = null ) {
$prepared = parent::prepare_line_items( $posted, $action, $item );
if ( ! $prepared->has_cogs() || ! $this->cogs_is_enabled() ) {
return $prepared;
}
$cogs_value = $posted['cost_of_goods_sold']['value'] ?? null;
if ( ! is_null( $cogs_value ) ) {
$prepared->set_cogs_value( (float) $cogs_value );
}
return $prepared;
}
/**
* Wrapper method to remove order items.
* When updating, the item ID provided is checked to ensure it is associated
@ -330,6 +355,48 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
'context' => array( 'edit' ),
);
if ( $this->cogs_is_enabled() ) {
$schema = $this->add_cogs_related_schema( $schema );
}
return $schema;
}
/**
* Add the Cost of Goods Sold related fields to the schema.
*
* @param array $schema The original schema.
* @return array The updated schema.
*/
private function add_cogs_related_schema( array $schema ): array {
$schema['properties']['cost_of_goods_sold'] = array(
'description' => __( 'Cost of Goods Sold data.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'properties' => array(
'total_value' => array(
'description' => __( 'Total value of the Cost of Goods Sold for the order.', 'woocommerce' ),
'type' => 'number',
'readonly' => true,
'context' => array( 'view', 'edit' ),
),
),
);
$schema['properties']['line_items']['items']['properties']['cost_of_goods_sold'] = array(
'description' => __( 'Cost of Goods Sold data. Only present for product line items.', 'woocommerce' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'properties' => array(
'total_value' => array(
'description' => __( 'Value of the Cost of Goods Sold for the order item.', 'woocommerce' ),
'type' => 'number',
'readonly' => true,
'context' => array( 'view', 'edit' ),
),
),
);
return $schema;
}
@ -354,4 +421,36 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
return $params;
}
/**
* Core method to prepare a single order object for response
* (doesn't fire hooks, execute rest_ensure_response, or add links).
*
* @param WC_Data $order Object data.
* @param WP_REST_Request $request Request object.
* @return array Prepared response data.
* @since 9.5.0
*/
protected function prepare_object_for_response_core( $order, $request ): array {
$cogs_is_enabled = $this->cogs_is_enabled();
$data = parent::prepare_object_for_response_core( $order, $request );
if ( isset( $data['line_items'] ) ) {
foreach ( $data['line_items'] as &$line_item_data ) {
if ( isset( $line_item_data['cogs_value'] ) ) {
if ( $cogs_is_enabled ) {
$line_item_data['cost_of_goods_sold']['value'] = $line_item_data['cogs_value'];
}
unset( $line_item_data['cogs_value'] );
}
}
}
if ( $cogs_is_enabled ) {
$data['cost_of_goods_sold']['total_value'] = $order->get_cogs_total_value();
}
return $data;
}
}

View File

@ -155,7 +155,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
$data = $this->filter_response_by_context( $data, $context );
if ( $this->cogs_is_enabled() ) {
$this->add_cogs_info_to_returned_data( $data, $object );
$this->add_cogs_info_to_returned_product_data( $data, $object );
}
$response = rest_ensure_response( $data );
@ -873,7 +873,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
);
if ( $this->cogs_is_enabled() ) {
$schema = $this->add_cogs_related_schema( $schema, true );
$schema = $this->add_cogs_related_product_schema( $schema, true );
}
return $this->add_additional_fields_schema( $schema );

View File

@ -1567,7 +1567,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
}
if ( $this->cogs_is_enabled() ) {
$schema = $this->add_cogs_related_schema( $schema, false );
$schema = $this->add_cogs_related_product_schema( $schema, false );
}
return $this->add_additional_fields_schema( $schema );
@ -1772,7 +1772,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
$data = parent::prepare_object_for_response_core( $object_data, $request, $context );
if ( $this->cogs_is_enabled() ) {
$this->add_cogs_info_to_returned_data( $data, $object_data );
$this->add_cogs_info_to_returned_product_data( $data, $object_data );
}
return $data;

View File

@ -5,7 +5,7 @@
namespace Automattic\WooCommerce\Admin\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
/**
* Contains backend logic for the Settings feature.
@ -37,9 +37,37 @@ class Settings {
}
add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'add_component_settings' ) );
// Run this after the original WooCommerce settings have been added.
add_action( 'admin_menu', array( $this, 'register_pages' ), 60 );
add_action( 'init', array( $this, 'redirect_core_settings_pages' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_settings_editor_scripts' ) );
}
/**
* Enqueue scripts for the settings editor.
*/
public function enqueue_settings_editor_scripts() {
$screen = get_current_screen();
if ( ! $screen || 'woocommerce_page_wc-settings' !== $screen->id ) {
return;
}
// Make sure the Settings Editor package is loaded.
wp_enqueue_script( 'wc-settings-editor' );
$script_handle = 'wc-admin-edit-settings';
$script_path_name = 'wp-admin-scripts/settings';
$script_assets_filename = WCAdminAssets::get_script_asset_filename( 'wp-admin-scripts', 'settings' );
$script_assets = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . 'wp-admin-scripts/' . $script_assets_filename;
$script_version = WCAdminAssets::get_file_version( 'js', $script_assets['version'] );
wp_register_script(
$script_handle,
WCAdminAssets::get_url( $script_path_name, 'js' ),
$script_assets['dependencies'],
$script_version,
true
);
// Load the main Settings script.
wp_enqueue_script( $script_handle );
}
/**
@ -63,78 +91,4 @@ class Settings {
return $settings;
}
/**
* Registers settings pages.
*/
public function register_pages() {
$controller = PageController::get_instance();
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$settings = array();
foreach ( $setting_pages as $setting_page ) {
$settings = $setting_page->add_settings_page( $settings );
}
$order = 0;
foreach ( $settings as $key => $setting ) {
$order += 10;
$settings_page = array(
'parent' => 'woocommerce-settings',
'title' => $setting,
'id' => 'settings-' . $key,
'path' => "/settings/$key",
);
// Replace the old menu with the first settings item.
if ( 10 === $order ) {
$this->replace_settings_page( $settings_page );
}
$controller->register_page( $settings_page );
}
}
/**
* Replace the Settings page in the original WooCommerce menu.
*
* @param array $page Page used to replace the original.
*/
protected function replace_settings_page( $page ) {
global $submenu;
// Check if WooCommerce parent menu has been registered.
if ( ! isset( $submenu['woocommerce'] ) ) {
return;
}
foreach ( $submenu['woocommerce'] as &$item ) {
// The "slug" (aka the path) is the third item in the array.
if ( 0 === strpos( $item[2], 'wc-settings' ) ) {
$item[2] = wc_admin_url( "&path={$page['path']}" );
}
}
}
/**
* Redirect the old settings page URLs to the new ones.
*/
public function redirect_core_settings_pages() {
/* phpcs:disable WordPress.Security.NonceVerification */
if ( ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] ) {
return;
}
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
$setting_pages = \WC_Admin_Settings::get_settings_pages();
$default_setting = isset( $setting_pages[0] ) ? $setting_pages[0]->get_id() : '';
$setting = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : $default_setting;
/* phpcs:enable */
wp_safe_redirect( wc_admin_url( "&path=/settings/$setting" ) );
exit;
}
}

View File

@ -410,7 +410,7 @@ class Checkout extends AbstractBlock {
$this->asset_data_registry->add( 'activeShippingZones', CartCheckoutUtils::get_shipping_zones() );
}
if ( $is_block_editor && ! $this->asset_data_registry->exists( 'globalPaymentMethods' ) ) {
if ( ! $this->asset_data_registry->exists( 'globalPaymentMethods' ) ) {
// These are used to show options in the sidebar. We want to get the full list of enabled payment methods,
// not just the ones that are available for the current cart (which may not exist yet).
$payment_methods = $this->get_enabled_payment_gateways();

View File

@ -174,13 +174,23 @@ final class ProductFilterAttribute extends AbstractBlock {
$product_attribute = wc_get_attribute( $block_attributes['attributeId'] );
$attribute_counts = $this->get_attribute_counts( $block, $product_attribute->slug, $block_attributes['queryType'] );
$hide_empty = $block_attributes['hideEmpty'] ?? true;
$attribute_terms = get_terms(
array(
'taxonomy' => $product_attribute->slug,
'include' => array_keys( $attribute_counts ),
)
);
if ( $hide_empty ) {
$attribute_terms = get_terms(
array(
'taxonomy' => $product_attribute->slug,
'include' => array_keys( $attribute_counts ),
)
);
} else {
$attribute_terms = get_terms(
array(
'taxonomy' => $product_attribute->slug,
'hide_empty' => false,
)
);
}
$selected_terms = array_filter(
explode(
@ -207,19 +217,8 @@ final class ProductFilterAttribute extends AbstractBlock {
$attribute_terms
);
$filtered_options = array_filter(
$attribute_options,
function ( $option ) use ( $block_attributes ) {
$hide_empty = $block_attributes['hideEmpty'] ?? true;
if ( $hide_empty ) {
return $option['rawData']['count'] > 0;
}
return true;
}
);
$filter_context = array(
'items' => $filtered_options,
'items' => $attribute_options,
'actions' => array(
'toggleFilter' => "{$this->get_full_block_name()}::actions.toggleFilter",
),

View File

@ -279,6 +279,7 @@ class WCAdminAssets {
'wc-navigation',
'wc-block-templates',
'wc-product-editor',
'wc-settings-editor',
'wc-remote-logging',
);

View File

@ -16,7 +16,7 @@ trait CogsAwareRestControllerTrait {
* @param array $data Array of response data.
* @param WC_Product $product Product to get the information from.
*/
private function add_cogs_info_to_returned_data( array &$data, $product ): void {
private function add_cogs_info_to_returned_product_data( array &$data, $product ): void {
if ( ! $this->cogs_is_enabled() ) {
return;
}
@ -68,7 +68,7 @@ trait CogsAwareRestControllerTrait {
* @param bool $for_variations_controller True if the information is for an endpoint in the variations controller.
* @return array Updated schema information.
*/
private function add_cogs_related_schema( array $schema, bool $for_variations_controller ): array {
private function add_cogs_related_product_schema( array $schema, bool $for_variations_controller ): array {
$schema['properties']['cost_of_goods_sold'] = array(
'description' => __( 'Cost of Goods Sold data.', 'woocommerce' ),
'type' => 'object',

View File

@ -20,4 +20,22 @@ trait CogsAwareUnitTestSuiteTrait {
private function disable_cogs_feature() {
delete_option( 'woocommerce_feature_cost_of_goods_sold_enabled' );
}
/**
* Sets the expectation for a "doing it wrong" being thrown.
*
* @param string $method_name The method name inside the error message.
*/
private function expect_doing_it_wrong_cogs_disabled( string $method_name ) {
$this->register_legacy_proxy_function_mocks(
array(
'wc_doing_it_wrong' => function ( $function_name, $message ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw new \Exception( "Doing it wrong, function: '$function_name', message: '$message'" );
},
)
);
$this->expectExceptionMessage( "Doing it wrong, function: '{$method_name}', message: 'The Cost of Goods sold feature is disabled, thus the method called will do nothing and will return dummy data.'" );
}
}

View File

@ -8,6 +8,7 @@ namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@ -23,6 +24,8 @@ defined( 'ABSPATH' ) || exit;
*/
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {
use CogsAwareTrait;
/**
* Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
*
@ -1244,12 +1247,18 @@ WHERE
$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
$post_orders = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();
$cogs_is_enabled = $this->cogs_is_enabled();
foreach ( $data as $order_data ) {
$order_id = absint( $order_data->id );
$order = $orders[ $order_id ];
$this->init_order_record( $order, $order_id, $order_data );
if ( $order->has_cogs() && $cogs_is_enabled ) {
$this->read_cogs_data( $order );
}
if ( $data_sync_enabled && $this->should_sync_order( $order ) && isset( $post_orders[ $order_id ] ) ) {
self::$reading_order_ids[] = $order_id;
$this->maybe_sync_order( $order, $post_orders[ $order->get_id() ] );
@ -1257,6 +1266,29 @@ WHERE
}
}
/**
* Read the Cost of Goods Sold value for a given order from the database, if available, and apply it to the order.
*
* @param \WC_Abstract_Order $order The order to get the COGS value for.
*/
private function read_cogs_data( WC_Abstract_Order $order ) {
$meta_entry = $this->data_store_meta->get_metadata_by_key( $order, '_cogs_total_value' );
$cogs_value = false === $meta_entry ? 0 : (float) current( $meta_entry )->meta_value;
/**
* Filter to customize the Cost of Goods Sold value that gets loaded for a given order.
*
* @since 9.5.0
*
* @param float $cogs_value The value as read from the database.
* @param WC_Abstract_Order $product The order for which the value is being loaded.
*/
$cogs_value = apply_filters( 'woocommerce_load_order_cogs_value', $cogs_value, $order );
$order->set_cogs_total_value( (float) $cogs_value );
$order->apply_changes();
}
/**
* Helper method to check whether to sync the order.
*
@ -1900,6 +1932,50 @@ FROM $order_meta_table
$this->update_address_index_meta( $order, $changes );
$default_taxonomies = $this->init_default_taxonomies( $order, array() );
$this->set_custom_taxonomies( $order, $default_taxonomies );
if ( $order->has_cogs() && $this->cogs_is_enabled() ) {
$this->save_cogs_data( $order );
}
}
/**
* Save the Cost of Goods Sold value of a given order to the database.
*
* @param WC_Abstract_Order $order The order to save the COGS value for.
*/
private function save_cogs_data( WC_Abstract_Order $order ) {
$cogs_value = $order->get_cogs_total_value();
/**
* Filter to customize the Cost of Goods Sold value that gets saved for a given order,
* or to suppress the saving of the value (so that custom storage can be used).
*
* @since 9.5.0
*
* @param float|null $cogs_value The value to be written to the database. If returned as null, nothing will be written.
* @param WC_Abstract_Order $item The order for which the value is being saved.
*/
$cogs_value = apply_filters( 'woocommerce_save_order_cogs_value', $cogs_value, $order );
if ( is_null( $cogs_value ) ) {
return;
}
$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_cogs_total_value' );
if ( 0.0 === $cogs_value && $existing_meta ) {
$existing_meta = current( $existing_meta );
$this->data_store_meta->delete_meta( $order, $existing_meta );
} elseif ( $existing_meta ) {
$existing_meta = current( $existing_meta );
$existing_meta->key = '_cogs_total_value';
$existing_meta->value = $cogs_value;
$this->data_store_meta->update_meta( $order, $existing_meta );
} else {
$meta = new \WC_Meta_Data();
$meta->key = '_cogs_total_value';
$meta->value = $cogs_value;
$this->data_store_meta->add_meta( $order, $meta );
}
}
/**

View File

@ -106,7 +106,7 @@ class RemoteLogger extends \WC_Log_Handler {
}
if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) {
$log_data['file'] = $this->sanitize( $context['error']['file'] );
$log_data['file'] = $this->normalize_paths( $context['error']['file'] );
unset( $context['error']['file'] );
}
@ -345,22 +345,34 @@ class RemoteLogger extends \WC_Log_Handler {
return false;
}
$wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH );
$wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH );
$wp_includes_dir = StringUtil::normalize_local_path_slashes( ABSPATH . WPINC );
$wp_admin_dir = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' );
// Check if the error message contains the WooCommerce plugin directory.
if ( str_contains( $message, $wc_plugin_dir ) ) {
return false;
}
// Check if the backtrace contains the WooCommerce plugin directory.
foreach ( $context['backtrace'] as $trace ) {
if ( is_string( $trace ) && str_contains( $trace, $wc_plugin_dir ) ) {
return false;
// Find the first relevant frame that is not from WordPress core and not empty.
$relevant_frame = null;
foreach ( $context['backtrace'] as $frame ) {
if ( empty( $frame ) || ! is_string( $frame ) ) {
continue;
}
if ( is_array( $trace ) && isset( $trace['file'] ) && str_contains( $trace['file'], $wc_plugin_dir ) ) {
return false;
// Skip frames from WordPress core.
if ( strpos( $frame, $wp_includes_dir ) !== false || strpos( $frame, $wp_admin_dir ) !== false ) {
continue;
}
$relevant_frame = $frame;
break;
}
// Check if the relevant frame is from WooCommerce.
if ( $relevant_frame && strpos( $relevant_frame, $wc_plugin_dir ) !== false ) {
return false;
}
if ( ! function_exists( 'apply_filters' ) ) {
@ -409,30 +421,58 @@ class RemoteLogger extends \WC_Log_Handler {
*
* 1. Remove the absolute path to the plugin directory based on WC_ABSPATH. This is more accurate than using WP_PLUGIN_DIR when the plugin is symlinked.
* 2. Remove the absolute path to the WordPress root directory.
* 3. Redact potential user data such as email addresses and phone numbers.
*
* For example, the trace:
*
* /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123
* will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123
*
* @param string $message The message to sanitize.
* @return string The sanitized message.
* Additionally, any user data like email addresses or phone numbers will be redacted.
*
* @param string $content The content to sanitize.
*
* @return string The sanitized content.
*/
private function sanitize( $message ) {
if ( ! is_string( $message ) ) {
return $message;
private function sanitize( $content ) {
if ( ! is_string( $content ) ) {
return $content;
}
$sanitized = $this->normalize_paths( $content );
$sanitized = $this->redact_user_data( $sanitized );
if ( ! function_exists( 'apply_filters' ) ) {
require_once ABSPATH . WPINC . '/plugin.php';
}
/**
* Filter the sanitized log content before it's sent to the remote logging service.
*
* @since 9.5.0
*
* @param string $sanitized The sanitized content.
* @param string $content The original content.
*/
return apply_filters( 'woocommerce_remote_logger_sanitized_content', $sanitized, $content );
}
/**
* Normalize file paths by replacing absolute paths with relative ones.
*
* @param string $content The content containing paths to normalize.
*
* @return string The content with normalized paths.
*/
private function normalize_paths( string $content ): string {
$plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) );
$wp_path = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) );
$sanitized = str_replace(
return str_replace(
array( $plugin_path, $wp_path ),
array( './', './' ),
$message
$content
);
return $sanitized;
}
/**
@ -470,6 +510,54 @@ class RemoteLogger extends \WC_Log_Handler {
return implode( "\n", $sanitized_trace );
}
/**
* Redact potential user data from the content.
*
* @param string $content The content to redact.
* @return string The redacted message.
*/
private function redact_user_data( $content ) {
// Redact email addresses.
$content = preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $content );
// Redact potential IP addresses.
$content = preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[redacted_ip]', $content );
// Redact potential credit card numbers.
$content = preg_replace( '/(\d{4}[- ]?){3}\d{4}/', '[redacted_credit_card]', $content );
// API key redaction patterns.
$api_patterns = array(
'/\b[A-Za-z0-9]{32,40}\b/', // Generic API key.
'/\b[0-9a-f]{32}\b/i', // 32 hex characters.
'/\b(?:[A-Z0-9]{4}-){3,7}[A-Z0-9]{4}\b/i', // Segmented API key (e.g., XXXX-XXXX-XXXX-XXXX).
'/\bsk_[A-Za-z0-9]{24,}\b/i', // Stripe keys (starts with sk_).
);
foreach ( $api_patterns as $pattern ) {
$content = preg_replace( $pattern, '[redacted_api_key]', $content );
}
/**
* Redact potential phone numbers.
*
* This will match patterns like:
* +1 (123) 456 7890 (with parentheses around area code)
* +44-123-4567-890 (with area code, no parentheses)
* 1234567890 (10 consecutive digits, no area code)
* (123) 456-7890 (area code in parentheses, groups)
* +91 12345 67890 (international format with space)
*/
$content = preg_replace(
'/(?:(?:\+?\d{1,3}[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}|\b\d{10,11}\b)/',
'[redacted_phone]',
$content
);
return $content;
}
/**
* Check if the current environment is development or local.
*

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 635 B

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,168 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Utilities\StringUtil;
use WC_Abstract_Order;
/**
* Class PaymentInfo.
*/
class PaymentInfo {
/**
* This array must contain all the names of the files in the CardIcons directory (without extension),
* except 'unknown'.
*/
private const KNOWN_CARD_BRANDS = array(
'amex',
'diners',
'discover',
'interac',
'jcb',
'mastercard',
'visa',
);
/**
* Get info about the card used for payment on an order.
*
* @param WC_Abstract_Order $order The order in question.
*
* @return array
*/
public static function get_card_info( WC_Abstract_Order $order ): array {
$method = $order->get_payment_method();
if ( 'woocommerce_payments' === $method ) {
$info = self::get_wcpay_card_info( $order );
} else {
/**
* Filter to allow payment gateways to provide payment card info for an order.
*
* @since 9.5.0
*
* @param array|null $info The card info.
* @param WC_Abstract_Order $order The order.
*/
$info = apply_filters( 'wc_order_payment_card_info', array(), $order );
if ( ! is_array( $info ) ) {
$info = array();
}
}
$defaults = array(
'payment_method' => $method,
'brand' => '',
'icon' => '',
'last4' => '',
);
$info = wp_parse_args( $info, $defaults );
if ( empty( $info['icon'] ) ) {
$info['icon'] = self::get_card_icon( $info['brand'] );
}
return $info;
}
/**
* Generate a CSS-compatible SVG icon of a card brand.
*
* @param string $brand The brand of the card.
*
* @return string
*/
private static function get_card_icon( ?string $brand ): string {
$brand = strtolower( (string) $brand );
if ( ! in_array( $brand, self::KNOWN_CARD_BRANDS, true ) ) {
$brand = 'unknown';
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
return base64_encode( file_get_contents( __DIR__ . "/CardIcons/{$brand}.svg" ) );
}
/**
* Get info about the card used for payment on an order, when the payment gateway is WooPayments.
*
* @see https://docs.stripe.com/api/charges/object#charge_object-payment_method_details
*
* @param WC_Abstract_Order $order The order in question.
*
* @return array
*/
private static function get_wcpay_card_info( WC_Abstract_Order $order ): array {
if ( 'woocommerce_payments' !== $order->get_payment_method() ) {
return array();
}
// For testing purposes: if WooCommerce Payments development mode is enabled, an order meta item with
// key '_wcpay_payment_details' will be used if it exists as a replacement for the call to the Stripe
// API's 'get intent' endpoint. The value must be the JSON encoding of an array simulating the
// "payment_details" part of the response from the endpoint.
$stored_payment_details = Constants::get_constant( 'WCPAY_DEV_MODE' ) ? $order->get_meta( '_wcpay_payment_details' ) : '';
$payment_details = json_decode( $stored_payment_details, true );
if ( ! $payment_details ) {
if ( ! class_exists( \WC_Payments::class ) ) {
return array();
}
$intent_id = $order->get_meta( '_intent_id' );
if ( ! $intent_id ) {
return array();
}
try {
$payment_details = \WC_Payments::get_payments_api_client()
->get_intent( $intent_id )
->get_charge()
->get_payment_method_details();
} catch ( Exception $ex ) {
$order_id = $order->get_id();
$message = $ex->getMessage();
wc_get_logger()->error(
sprintf(
'%s - retrieving info for charge %s for order %s: %s',
StringUtil::class_name_without_namespace( static::class ),
$intent_id,
$order_id,
$message
),
array(
'source' => 'payment-info',
)
);
return array();
}
}
$card_info = array();
if ( isset( $payment_details['type'], $payment_details[ $payment_details['type'] ] ) ) {
$details = $payment_details[ $payment_details['type'] ];
switch ( $payment_details['type'] ) {
case 'card':
default:
$card_info['brand'] = $details['brand'] ?? '';
$card_info['last4'] = $details['last4'] ?? '';
break;
case 'card_present':
case 'interac_present':
$card_info['brand'] = $details['brand'] ?? '';
$card_info['last4'] = $details['last4'] ?? '';
$card_info['account_type'] = $details['receipt']['account_type'] ?? '';
$card_info['aid'] = $details['receipt']['dedicated_file_name'] ?? '';
$card_info['app_name'] = $details['receipt']['application_preferred_name'] ?? '';
break;
}
}
return array_map( 'sanitize_text_field', $card_info );
}
}

View File

@ -2,12 +2,12 @@
namespace Automattic\WooCommerce\Internal\ReceiptRendering;
use Automattic\WooCommerce\Internal\Orders\PaymentInfo;
use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Exception;
use WC_Order;
use WC_Abstract_Order;
/**
* This class generates printable order receipts as transient files (see src/Internal/TransientFiles).
@ -83,15 +83,15 @@ class ReceiptRenderingEngine {
* transient file is created with the supplied expiration date (defaulting to "tomorrow"), and the new file name
* is stored as order meta with the key RECEIPT_FILE_NAME_META_KEY.
*
* @param int|WC_Order $order The order object or order id to get the receipt for.
* @param string|int|null $expiration_date GMT expiration date formatted as yyyy-mm-dd, or as a timestamp, or null for "tomorrow".
* @param bool $force_new If true, creates a new receipt file even if one already exists for the order.
* @param int|WC_Abstract_Order $order The order object or order id to get the receipt for.
* @param string|int|null $expiration_date GMT expiration date formatted as yyyy-mm-dd, or as a timestamp, or null for "tomorrow".
* @param bool $force_new If true, creates a new receipt file even if one already exists for the order.
* @return string|null The file name of the new or already existing receipt file, null if an order id is passed and the order doesn't exist.
* @throws InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past).
* @throws Exception The directory to store the file doesn't exist and can't be created.
*/
public function generate_receipt( $order, $expiration_date = null, bool $force_new = false ): ?string {
if ( ! $order instanceof WC_Order ) {
if ( ! $order instanceof WC_Abstract_Order ) {
$order = wc_get_order( $order );
if ( false === $order ) {
return null;
@ -126,7 +126,7 @@ class ReceiptRenderingEngine {
* See the template file, Templates/order-receipt.php, for reference on how the data is used.
*
* @param array $data The original set of data.
* @param WC_Order $order The order for which the receipt is being generated.
* @param WC_Abstract_Order $order The order for which the receipt is being generated.
* @returns array The updated set of data.
*
* @since 9.0.0
@ -164,7 +164,7 @@ class ReceiptRenderingEngine {
*
* @param string $line_item_display_data Data to use to generate the HTML table row to be rendered for the line item.
* @param array $line_item_data The relevant data for the line item for which the HTML table row is being generated.
* @param WC_Order $order The order for which the receipt is being generated.
* @param WC_Abstract_Order $order The order for which the receipt is being generated.
* @return string The actual data to use to generate the HTML for the line item.
*
* @since 9.0.0
@ -191,7 +191,7 @@ class ReceiptRenderingEngine {
* See Templates/order-receipt-css.php for the original CSS styles.
*
* @param string $css The original CSS styles to use.
* @param WC_Order $order The order for which the receipt is being generated.
* @param WC_Abstract_Order $order The order for which the receipt is being generated.
* @return string The actual CSS styles that will be used.
*
* @since 9.0.0
@ -243,12 +243,12 @@ class ReceiptRenderingEngine {
* A receipt is considered to be available for the order if there's an order meta entry with key
* RECEIPT_FILE_NAME_META_KEY AND the transient file it points to exists AND it has not expired.
*
* @param WC_Order $order The order object or order id to get the receipt for.
* @param WC_Abstract_Order $order The order object or order id to get the receipt for.
* @return string|null The receipt file name, or null if no receipt is currently available for the order.
* @throws Exception Thrown if a wrong file path is passed.
*/
public function get_existing_receipt( $order ): ?string {
if ( ! $order instanceof WC_Order ) {
if ( ! $order instanceof WC_Abstract_Order ) {
$order = wc_get_order( $order );
if ( false === $order ) {
return null;
@ -272,10 +272,10 @@ class ReceiptRenderingEngine {
/**
* Get the order data that the receipt template will use.
*
* @param WC_Order $order The order to get the data from.
* @param WC_Abstract_Order $order The order to get the data from.
* @return array The order data as an associative array.
*/
private function get_order_data( WC_Order $order ): array {
private function get_order_data( WC_Abstract_Order $order ): array {
$store_name = get_bloginfo( 'name' );
if ( $store_name ) {
/* translators: %s = store name */
@ -414,61 +414,20 @@ class ReceiptRenderingEngine {
* - Retrieving the payment information from Stripe API (providing the intent id) fails.
* - The received data set doesn't contain the expected information.
*
* @param WC_Order $order The order to get the data from.
* @param WC_Abstract_Order $order The order to get the data from.
* @return array|null An array of payment information for the order, or null if not available.
*/
private function get_woo_pay_data( WC_Order $order ): ?array {
// For testing purposes: if WooCommerce Payments development mode is enabled,
// an order meta item with key '_wcpay_payment_details' will be used if it exists as a replacement
// for the call to the Stripe API's 'get intent' endpoint.
// The value must be the JSON encoding of an array simulating the "payment_details" part of the response from the endpoint
// (at the very least it must contain the "card_present" key).
$payment_details = json_decode( defined( 'WCPAY_DEV_MODE' ) && WCPAY_DEV_MODE ? $order->get_meta( '_wcpay_payment_details' ) : false, true );
private function get_woo_pay_data( WC_Abstract_Order $order ): ?array {
$card_info = PaymentInfo::get_card_info( $order );
if ( ! $payment_details ) {
if ( 'woocommerce_payments' !== $order->get_payment_method() ) {
return null;
}
if ( ! class_exists( \WC_Payments::class ) ) {
return null;
}
$intent_id = $order->get_meta( '_intent_id' );
if ( ! $intent_id ) {
return null;
}
try {
$payment_details = \WC_Payments::get_payments_api_client()->get_intent( $intent_id )->get_charge()->get_payment_method_details();
} catch ( Exception $ex ) {
$order_id = $order->get_id();
$message = $ex->getMessage();
wc_get_logger()->error( StringUtil::class_name_without_namespace( static::class ) . " - retrieving info for charge {$intent_id} for order {$order_id}: {$message}" );
return null;
}
}
$card_data = $payment_details['card_present'] ?? null;
if ( is_null( $card_data ) ) {
if ( empty( $card_info ) ) {
return null;
}
$card_brand = $card_data['brand'] ?? '';
if ( ! in_array( $card_brand, self::KNOWN_CARD_TYPES, true ) ) {
$card_brand = 'unknown';
}
// Backcompat for custom templates.
$card_info['card_icon'] = $card_info['icon'];
$card_info['card_last4'] = $card_info['last4'];
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$card_svg = base64_encode( file_get_contents( __DIR__ . "/CardIcons/{$card_brand}.svg" ) );
return array(
'payment_method' => 'woocommerce_payments',
'card_icon' => $card_svg,
'card_last4' => wp_kses( $card_data['last4'] ?? '', array() ),
'app_name' => wp_kses( $card_data['receipt']['application_preferred_name'] ?? null, array() ),
'aid' => wp_kses( $card_data['receipt']['dedicated_file_name'] ?? null, array() ),
'account_type' => wp_kses( $card_data['receipt']['account_type'] ?? null, array() ),
);
return $card_info;
}
}

View File

@ -51,7 +51,11 @@
}
}
if ( isset( $data['payment_info'] ) ) {
if (
! empty( $data['payment_info']['app_name'] )
|| ! empty( $data['payment_info']['aid'] )
|| ! empty( $data['payment_info']['account_type'] )
) {
?>
<footer>
<p id="payment_info">

View File

@ -188,6 +188,13 @@ abstract class AbstractCartRoute extends AbstractRoute {
* @return string
*/
protected function get_cart_token() {
// Ensure cart is loaded.
$this->cart_controller->load_cart();
if ( ! wc()->session ) {
return null;
}
return JsonWebToken::create(
[
'user_id' => wc()->session->get_customer_id(),

View File

@ -6,11 +6,23 @@
* @since 3.2.0
*/
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
/**
* Order Item Product unit tests.
*/
class WC_Tests_Order_Item_Product extends WC_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
/**
* Runs after each test.
*/
public function tearDown(): void {
parent::tearDown();
$this->disable_cogs_feature();
}
/**
* Test generic setters and getters for WC_Order_Item_Product.
*
@ -382,4 +394,45 @@ class WC_Tests_Order_Item_Product extends WC_Unit_Test_Case {
$item->add_meta_data( 'foo', 'bar' );
$this->assertEquals( 'bar', $item->get_meta( 'foo' ) );
}
/**
* @testdox Product order items manage a Cost of Goods Sold value.
*/
public function test_has_cogs() {
$product_item = new WC_Order_Item_Product();
$this->assertTrue( $product_item->has_cogs() );
}
/**
* @testdox The Cost of Goods Sold value is calculated as the product cost times the quantity.
*/
public function test_cogs_is_calculated_as_product_cogs_times_quantity() {
$this->enable_cogs_feature();
$product = new WC_Product_Simple();
$product->set_cogs_value( 12.34 );
$product->save();
$product_item = new WC_Order_Item_Product();
$product_item->set_product( $product );
$product_item->set_quantity( 3 );
$product_item->save();
$this->assertTrue( $product_item->calculate_cogs_value() );
$this->assertEquals( 12.34 * 3, $product_item->get_cogs_value() );
}
/**
* @testdox The Cost of Goods Sold value calculation fails if the product can't be retrieved, and then the current value isn't modified.
*/
public function test_cogs_calculation_fails_if_product_cant_be_retrieved() {
$this->enable_cogs_feature();
$product_item = new WC_Order_Item_Product();
$product_item->set_cogs_value( 12.34 );
$this->assertFalse( $product_item->calculate_cogs_value() );
$this->assertEquals( 12.34, $product_item->get_cogs_value() );
}
}

View File

@ -0,0 +1,218 @@
<?php
/**
* Tests for the WC_Order_Item class.
*
* @package WooCommerce\Tests\Order_Item
*/
declare( strict_types=1 );
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
/**
* Tests for the WC_Order_Item class.
*/
class WC_Tests_Base_Order_Item extends WC_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
/**
* The system under test.
*
* @var WC_Order_Item
*/
private WC_Order_Item $sut;
/**
* Runs before each test.
*/
public function setUp(): void {
parent::setUp();
// phpcs:disable Squiz.Commenting
$this->sut = new class() extends WC_Order_Item {
public bool $has_cogs_value = false;
public $cogs_core_value = -1;
public function __construct( $item = 0 ) {
$this->data['cogs_value'] = null;
parent::__construct( $item );
}
public function has_cogs(): bool {
return $this->has_cogs_value;
}
protected function calculate_cogs_value_core(): ?float {
return -1 === $this->cogs_core_value ? parent::calculate_cogs_value_core() : $this->cogs_core_value;
}
};
// phpcs:enable Squiz.Commenting
}
/**
* Runs after each test.
*/
public function tearDown(): void {
parent::tearDown();
$this->disable_cogs_feature();
remove_all_filters( 'woocommerce_calculated_order_item_cogs_value' );
}
/**
* @testdox Order item classes don't manage Cost of Goods Sold by default.
*/
public function test_order_items_dont_have_cogs_by_default() {
$sut = new WC_Order_Item();
$this->assertFalse( $sut->has_cogs() );
}
/**
* @testdox 'calculate_cogs_value' returns false, and 'doing it wrong' is thrown, if the Cost of Goods Sold feature is disabled.
*/
public function test_calculate_cogs_simply_returns_false_if_cogs_disabled() {
$this->sut->has_cogs_value = true;
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Order_Item::calculate_cogs_value' );
$this->assertFalse( $this->sut->calculate_cogs_value() );
}
/**
* @testdox 'calculate_cogs_value' returns false if the Cost of Goods Sold feature is enabled but the class doesn't manage it.
*/
public function test_calculate_cogs_simply_returns_false_if_cogs_enabled_but_class_has_no_cogs() {
$this->enable_cogs_feature();
$this->assertFalse( $this->sut->calculate_cogs_value() );
}
/**
* @testdox 'calculate_cogs_value' throws an exception if the derived class doesn't override the default method implementation.
*/
public function test_calculate_cogs_throws_if_class_does_not_override_core_calculation() {
$this->sut->has_cogs_value = true;
$this->enable_cogs_feature();
$this->expectExceptionMessage( 'Method WC_Order_Item::calculate_cogs_value_core is not implemented. Classes overriding has_cogs must override this method too.' );
$this->sut->calculate_cogs_value();
}
/**
* @testdox 'calculate_cogs_value' sets the value returned by the 'calculate_cogs_core' override, and returns true.
*/
public function test_calculate_cogs_sets_value_from_core_calculation_overriden_in_child_class() {
$this->sut->has_cogs_value = true;
$this->enable_cogs_feature();
$this->sut->cogs_core_value = 12.34;
$this->assertTrue( $this->sut->calculate_cogs_value() );
$this->assertEquals( 12.34, $this->sut->get_cogs_value() );
}
/**
* @testdox 'get_cogs_value' returns zero if the Cost of Goods Sold feature is enabled but the class doesn't manage it.
*/
public function test_get_cogs_value_returns_zero_if_item_has_no_cogs() {
$this->sut->cogs_core_value = 12.34;
$this->enable_cogs_feature();
$this->assertEquals( 0, $this->sut->get_cogs_value() );
}
/**
* @testdox 'get_cogs_value' returns zero, and 'doing it wrong' is thrown, if the Cost of Goods Sold feature is disabled.
*/
public function test_get_cogs_value_returns_zero_if_cogs_feature_is_not_enabled() {
$this->sut->cogs_core_value = 12.34;
$this->sut->has_cogs_value = true;
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Order_Item::get_cogs_value' );
$this->assertEquals( 0, $this->sut->get_cogs_value() );
}
/**
* @testdox 'set_cogs_value' does nothing if the Cost of Goods Sold feature is enabled but the class doesn't manage it.
*/
public function test_set_cogs_value_does_nothing_if_item_has_no_cogs() {
$this->enable_cogs_feature();
$this->sut->set_cogs_value( 12.34 );
$this->assertEquals( 0, $this->sut->get_cogs_value() );
}
/**
* @testdox 'set_cogs_value' does nothing, and 'doing it wrong' is thrown, if the Cost of Goods Sold feature is disabled.
*/
public function test_set_cogs_value_does_nothing_if_cogs_feature_is_not_enabled() {
$this->sut->has_cogs_value = true;
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Order_Item::set_cogs_value' );
$this->sut->set_cogs_value( 12.34 );
$this->assertEquals( 0, $this->sut->get_cogs_value() );
}
/**
* @testdox 'set_cogs_value' properly sets the value then the Cost of Goods Sold feature is enabled and the class manages it.
*/
public function test_set_cogs_value_works_as_expected_if_item_has_cogs_and_feature_is_enabled() {
$this->sut->has_cogs_value = true;
$this->enable_cogs_feature();
$this->sut->set_cogs_value( 12.34 );
$this->assertEquals( 12.34, $this->sut->get_cogs_value() );
}
/**
* @testdox 'calculate_cogs_value_core' can return null to signal that the calculation failed.
*/
public function test_calculate_core_returning_null_means_failure_in_calculation() {
$this->sut->has_cogs_value = true;
$this->enable_cogs_feature();
$this->sut->cogs_core_value = null;
$this->sut->set_cogs_value( 12.34 );
$this->assertFalse( $this->sut->calculate_cogs_value() );
$this->assertEquals( 12.34, $this->sut->get_cogs_value() );
}
/**
* @testdox The calculated value for Cost of Goods Sold can be modified using the 'woocommerce_calculated_order_item_cogs_value' filter, returning null means a failure in the calculation.
*
* @testWith [90.12, true, 90.12]
* [null, false, 56.78]
*
* @param mixed $value_returned_by_filter The value that the filter will return.
* @param bool $expected_calculate_method_result The expected value returned by 'calculate_cogs_value'.
* @param float $expected_obtained_cogs_value The expected value returned by 'get_cogs_value'.
* @return void
*/
public function test_filter_can_be_used_to_alter_calculated_cogs_value( $value_returned_by_filter, bool $expected_calculate_method_result, float $expected_obtained_cogs_value ) {
$filter_received_value = null;
$filter_received_item = null;
$this->sut->has_cogs_value = true;
$this->enable_cogs_feature();
$this->sut->cogs_core_value = 12.34;
$this->sut->set_cogs_value( 56.78 );
add_filter(
'woocommerce_calculated_order_item_cogs_value',
function ( $value, $item ) use ( &$filter_received_value, &$filter_received_item, $value_returned_by_filter ) {
$filter_received_value = $value;
$filter_received_item = $item;
return $value_returned_by_filter;
},
10,
2
);
$calculate_method_result = $this->sut->calculate_cogs_value();
$this->assertEquals( $expected_calculate_method_result, $calculate_method_result );
$this->assertEquals( $expected_obtained_cogs_value, $this->sut->get_cogs_value() );
$this->assertEquals( 12.34, $filter_received_value );
$this->assertSame( $this->sut, $filter_received_item );
}
}

View File

@ -5,6 +5,7 @@
* @package WooCommerce\Tests\Abstracts
*/
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
// phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- Backward compatibility.
@ -13,6 +14,8 @@ use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
*/
class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
/**
* Test when rounding is different when doing per line and in subtotal.
*/
@ -413,4 +416,139 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertEquals( $item2->get_id(), array_keys( $order2->get_items( 'line_item' ) )[0] );
}
/**
* @testdox Abstract order classes don't manage Cost of Goods Sold by default.
*/
public function test_abstract_orders_dont_have_cogs_by_default() {
$order = new class() extends WC_Abstract_Order {
};
$this->assertFalse( $order->has_cogs() );
}
/**
* @testdox The regular order class manages a Cost of Goods Sold value.
*/
public function test_orders_have_cogs() {
$order = new WC_Order();
$this->assertTrue( $order->has_cogs() );
}
/**
* @testdox 'calculate_cogs_total_value' returns zero, and 'doing it wrong' is thrown, if the Cost of Goods Sold feature is disabled.
*/
public function test_calculate_total_cogs_simply_returns_false_if_cogs_disabled() {
$order = new WC_Order();
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Abstract_Order::calculate_cogs_total_value' );
$this->assertEquals( 0, $order->calculate_cogs_total_value() );
}
/**
* @testdox 'calculate_cogs_total_value' returns false if the Cost of Goods Sold feature is enabled but the class doesn't manage it.
*/
public function test_calculate_cogs_simply_returns_false_if_cogs_enabled_but_class_has_no_cogs() {
$this->enable_cogs_feature();
// phpcs:disable Squiz.Commenting
$order = new class() extends WC_Order {
public function has_cogs(): bool {
return false;
}
};
// phpcs:enable Squiz.Commenting
$this->add_product_with_cogs_to_order( $order, 12.34, 1 );
$this->assertEquals( 0, $order->calculate_cogs_total_value() );
}
/**
* @testdox 'calculate_cogs_total_value' calculates the value from the prices and the quantities of all the items with a Cost of Goods Sold value.
*/
public function test_calculate_cogs_uses_product_info_and_sets_the_value() {
$this->enable_cogs_feature();
$order = new WC_Order();
$this->add_product_with_cogs_to_order( $order, 12.34, 2 );
$this->add_product_with_cogs_to_order( $order, 56.78, 3 );
$fee = new WC_Order_Item_Fee(); // Example of line item without COGS.
$order->add_item( $fee );
$calculated_value = $order->calculate_cogs_total_value();
$this->assertEquals( 12.34 * 2 + 56.78 * 3, $calculated_value );
$this->assertEquals( $calculated_value, $order->get_cogs_total_value() );
}
/**
* @testdox The 'calculate_cogs_total_value_core' method can be overridden in derived classes.
*/
public function test_calculate_cogs_core_can_be_overridden() {
$this->enable_cogs_feature();
// phpcs:disable Squiz.Commenting
$order = new class() extends WC_Order {
protected function calculate_cogs_total_value_core(): float {
return 999.34;
}
};
// phpcs:enable Squiz.Commenting
$this->add_product_with_cogs_to_order( $order, 12.34, 2 );
$calculated_value = $order->calculate_cogs_total_value();
$this->assertEquals( 999.34, $calculated_value );
$this->assertEquals( $calculated_value, $order->get_cogs_total_value() );
}
/**
* @testdox The calculated value for Cost of Goods Sold can be modified using the 'woocommerce_calculated_order_cogs_value' filter.
*/
public function test_filter_can_be_used_to_alter_calculated_cogs_value() {
$filter_received_value = null;
$filter_received_order = null;
$this->enable_cogs_feature();
$order = new WC_Order();
$this->add_product_with_cogs_to_order( $order, 12.34, 2 );
$this->add_product_with_cogs_to_order( $order, 56.78, 3 );
add_filter(
'woocommerce_calculated_order_cogs_value',
function ( $value, $order ) use ( &$filter_received_value, &$filter_received_order ) {
$filter_received_value = $value;
$filter_received_order = $order;
return 999.34;
},
10,
2
);
$calculate_method_result = $order->calculate_cogs_total_value();
$this->assertEquals( 12.34 * 2 + 56.78 * 3, $filter_received_value );
$this->assertEquals( 999.34, $calculate_method_result );
$this->assertEquals( $calculate_method_result, $order->get_cogs_total_value() );
$this->assertSame( $order, $filter_received_order );
}
/**
* Add a product order item with a given Cost of Goods Sold to an exising order.
*
* @param WC_Order $order The target order.
* @param float $cogs_value The COGS value of the product.
* @param int $quantity The quantity of the order item.
*/
private function add_product_with_cogs_to_order( WC_Order $order, float $cogs_value, int $quantity ) {
$product = WC_Helper_Product::create_simple_product();
$product->set_cogs_value( $cogs_value );
$product->save();
$item = new WC_Order_Item_Product();
$item->set_product( $product );
$item->set_quantity( $quantity );
$item->save();
$order->add_item( $item );
}
}

View File

@ -294,7 +294,7 @@ class WC_Abstract_Product_Test extends WC_Unit_Test_Case {
$product = WC_Helper_Product::create_simple_product();
$product->set_cogs_value( 12.34 );
add_filter( 'woocommerce_get_cogs_total_value', fn( $value, $product ) => $value + $product->get_id(), 10, 2 );
add_filter( 'woocommerce_get_product_cogs_total_value', fn( $value, $product ) => $value + $product->get_id(), 10, 2 );
$this->assertEquals( 12.34 + $product->get_id(), $product->get_cogs_total_value() );
}

View File

@ -0,0 +1,215 @@
<?php
declare( strict_types=1 );
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
/**
* Tests for the Abstract_WC_Order_Item_Type_Data_Store class.
*
* @package WooCommerce\Tests\Order_Item
*/
class WC_Order_Item_Data_Store_Test extends WC_Unit_Test_Case {
use CogsAwareUnitTestSuiteTrait;
/**
* The instance of the order items data store to use.
*
* @var WC_Data_Store
*/
private static WC_Data_Store $order_item_data_store;
/**
* Runs before all the tests of the class.
*/
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
self::$order_item_data_store = WC_Data_Store::load( 'order-item' );
}
/**
* Runs after each test.
*/
public function tearDown(): void {
parent::tearDown();
$this->disable_cogs_feature();
remove_all_filters( 'woocommerce_save_order_item_cogs_value' );
remove_all_filters( 'woocommerce_load_order_item_cogs_value' );
}
/**
* @testdox The Cost of Goods Sold value for order items is not persisted when the item is saved if the feature is disabled.
*/
public function test_cogs_is_not_persisted_when_cogs_is_disabled() {
$item = new WC_Order_Item_Product();
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Order_Item::set_cogs_value' );
$item->set_cogs_value( 12.34 );
$item->save();
$this->assertEquals( '', self::$order_item_data_store->get_metadata( $item->get_id(), '_cogs_value', true ) );
}
/**
* @testdox The Cost of Goods Sold value for order items is not persisted when the item is saved if the feature is enabled but the item doesn't manage it.
*/
public function test_cogs_is_not_persisted_when_cogs_is_enabled_but_item_has_no_cogs() {
$this->enable_cogs_feature();
// phpcs:disable Squiz.Commenting
$item = new class() extends WC_Order_Item_Product {
public function has_cogs(): bool {
return false;
}
};
// phpcs:enable Squiz.Commenting
$item->set_cogs_value( 12.34 );
$item->save();
$this->assertEquals( '', self::$order_item_data_store->get_metadata( $item->get_id(), '_cogs_value', true ) );
}
/**
* @testdox The Cost of Goods Sold value for order items is persisted when the item is saved, only when the value is not zero, if the feature is enabled and the item manages it.
*/
public function test_cogs_is_persisted_only_when_value_is_non_zero() {
$this->enable_cogs_feature();
$item = new WC_Order_Item_Product();
$item->set_cogs_value( 12.34 );
$item->save();
$this->assertEquals( 12.34, (float) self::$order_item_data_store->get_metadata( $item->get_id(), '_cogs_value', true ) );
$item->set_cogs_value( 0 );
$item->save();
$this->assertEquals( '', self::$order_item_data_store->get_metadata( $item->get_id(), '_cogs_value', true ) );
}
/**
* @testdox It's possible to modify the Cost of Goods Sold value that gets persisted for an order item using the 'woocommerce_save_order_item_cogs_value' filter, returning null suppresses the saving.
*
* @testWith [56.78, "56.78"]
* [null, "12.34"]
*
* @param mixed $filter_return_value The value that the filter will return.
* @param string $expected_saved_value The value that is expected to be persisted after the save attempt.
*/
public function test_saved_cogs_value_can_be_altered_via_filter_with_null_meaning_dont_save( $filter_return_value, string $expected_saved_value ) {
$received_filter_cogs_value = null;
$received_filter_item = null;
$this->enable_cogs_feature();
$item = new WC_Order_Item_Product();
$item->set_cogs_value( 12.34 );
$item->save();
add_filter(
'woocommerce_save_order_item_cogs_value',
function ( $cogs_value, $item ) use ( &$received_filter_cogs_value, &$received_filter_item, $filter_return_value ) {
$received_filter_cogs_value = $cogs_value;
$received_filter_item = $item;
return $filter_return_value;
},
10,
2
);
$item->set_cogs_value( 56.78 );
$item->save();
$this->assertEquals( 56.78, $received_filter_cogs_value );
$this->assertSame( $item, $received_filter_item );
$this->assertEquals( $expected_saved_value, self::$order_item_data_store->get_metadata( $item->get_id(), '_cogs_value', true ) );
}
/**
* @testdox The Cost of Goods Sold value for order items is not retrieved from database when the item is loaded if the feature is disabled.
*/
public function test_cogs_is_not_loaded_when_cogs_is_disabled() {
$item = new WC_Order_Item_Product();
$item->save();
self::$order_item_data_store->add_metadata( $item->get_id(), '_cogs_value', '12.34', true );
$item2 = new WC_Order_Item_Product( $item->get_id() );
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Order_Item::get_cogs_value' );
$this->assertEquals( 0, $item2->get_cogs_value() );
}
/**
* @testdox The Cost of Goods Sold value for order items is not retrieved from database when the item is loaded if the feature is enabled but the item doesn't manage it.
*/
public function test_cogs_is_not_loaded_when_cogs_is_enabled_but_item_has_no_cogs() {
$this->enable_cogs_feature();
$item = new WC_Order_Item_Product();
$item->save();
self::$order_item_data_store->add_metadata( $item->get_id(), '_cogs_value', '12.34', true );
// phpcs:disable Squiz.Commenting
$item2 = new class($item->get_id()) extends WC_Order_Item_Product {
public function has_cogs(): bool {
return false;
}
};
// phpcs:enable Squiz.Commenting
$this->assertEquals( 0, $item2->get_cogs_value() );
}
/**
* @testdox The Cost of Goods Sold value for order items is retrieved from database when the item is loaded if the feature is enabled and the item manages it.
*/
public function test_cogs_is_loaded_when_cogs_is_enabled_and_item_has_cogs() {
$this->enable_cogs_feature();
$item = new WC_Order_Item_Product();
$item->save();
self::$order_item_data_store->add_metadata( $item->get_id(), '_cogs_value', '12.34', true );
$item2 = new WC_Order_Item_Product( $item->get_id() );
$this->assertEquals( 12.34, $item2->get_cogs_value() );
}
/**
* @testdox It's possible to modify the Cost of Goods Sold value that gets loaded from the database for an order item using the 'woocommerce_load_order_item_cogs_value' filter.
*/
public function test_loaded_cogs_value_can_be_modified_via_filter() {
$received_filter_cogs_value = null;
$received_filter_item = null;
$this->enable_cogs_feature();
$item = new WC_Order_Item_Product();
$item->save();
self::$order_item_data_store->add_metadata( $item->get_id(), '_cogs_value', '12.34', true );
add_filter(
'woocommerce_load_order_item_cogs_value',
function ( $cogs_value, $item ) use ( &$received_filter_cogs_value, &$received_filter_item ) {
$received_filter_cogs_value = $cogs_value;
$received_filter_item = $item;
return 56.78;
},
10,
2
);
$item2 = new WC_Order_Item_Product( $item->get_id() );
$this->assertEquals( 12.34, $received_filter_cogs_value );
$this->assertSame( $item2, $received_filter_item );
$this->assertEquals( 56.78, $item2->get_cogs_value() );
}
}

View File

@ -254,7 +254,7 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$product->set_cogs_value( 12.34 );
$product->save();
add_filter( 'woocommerce_load_cogs_value', fn( $value, $product ) => $value + $product->get_id(), 10, 2 );
add_filter( 'woocommerce_load_product_cogs_value', fn( $value, $product ) => $value + $product->get_id(), 10, 2 );
$product = wc_get_product( $product->get_id() );
$this->assertEquals( 12.34 + $product->get_id(), $product->get_cogs_value() );
@ -266,7 +266,7 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
public function test_cogs_saved_value_can_be_altered_via_filter() {
$this->enable_cogs_feature();
add_filter( 'woocommerce_save_cogs_value', fn( $value, $product ) => $value + $product->get_id(), 10, 2 );
add_filter( 'woocommerce_save_product_cogs_value', fn( $value, $product ) => $value + $product->get_id(), 10, 2 );
$product = new WC_Product();
$product->set_cogs_value( 12.34 );
@ -286,7 +286,7 @@ class WC_Product_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$product->save();
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
add_filter( 'woocommerce_save_cogs_value', fn( $value, $product ) => null, 10, 2 );
add_filter( 'woocommerce_save_product_cogs_value', fn( $value, $product ) => null, 10, 2 );
$product->set_cogs_value( 56.78 );
$product->save();

View File

@ -15,8 +15,8 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
public function tearDown(): void {
parent::tearDown();
$this->disable_cogs_feature();
remove_all_filters( 'woocommerce_load_cogs_overrides_parent_value_flag' );
remove_all_filters( 'woocommerce_save_cogs_overrides_parent_value_flag' );
remove_all_filters( 'woocommerce_load_product_cogs_overrides_parent_value_flag' );
remove_all_filters( 'woocommerce_save_product_cogs_overrides_parent_value_flag' );
}
/**
@ -64,7 +64,7 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
}
/**
* @testdox Loaded Cost of Goods Sold "value overrides parent" flag can be modified using the woocommerce_load_cogs_overrides_parent_value_flag filter.
* @testdox Loaded Cost of Goods Sold "value overrides parent" flag can be modified using the woocommerce_load_product_cogs_overrides_parent_value_flag filter.
*
* @testWith [true]
* [false]
@ -79,14 +79,14 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$product->save();
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
add_filter( 'woocommerce_load_cogs_overrides_parent_value_flag', fn( $value, $product ) => ! $value, 10, 2 );
add_filter( 'woocommerce_load_product_cogs_overrides_parent_value_flag', fn( $value, $product ) => ! $value, 10, 2 );
$product = wc_get_product( $product->get_id() );
$this->assertEquals( ! $flag_value, $product->get_cogs_value_overrides_parent() );
}
/**
* @testdox Saved Cost of Goods Sold "value overrides parent" flag can be modified using the woocommerce_save_cogs_overrides_parent_value_flag filter.
* @testdox Saved Cost of Goods Sold "value overrides parent" flag can be modified using the woocommerce_save_product_cogs_overrides_parent_value_flag filter.
*
* @testWith [true]
* [false]
@ -97,7 +97,7 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$this->enable_cogs_feature();
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
add_filter( 'woocommerce_save_cogs_overrides_parent_value_flag', fn( $value, $product ) => ! $value, 10, 2 );
add_filter( 'woocommerce_save_product_cogs_overrides_parent_value_flag', fn( $value, $product ) => ! $value, 10, 2 );
$product = $this->get_variation();
$product->set_cogs_value_overrides_parent( $flag_value );
@ -108,7 +108,7 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
}
/**
* @testdox Saving of the Cost of Goods Sold "value overrides parent" flag can be suppressed using the woocommerce_save_cogs_overrides_parent_value_flag filter with a return value of null.
* @testdox Saving of the Cost of Goods Sold "value overrides parent" flag can be suppressed using the woocommerce_save_product_cogs_overrides_parent_value_flag filter with a return value of null.
*
* @testWith [true]
* [false]
@ -123,7 +123,7 @@ class WC_Product_Variation_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$product->save();
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
add_filter( 'woocommerce_save_cogs_overrides_parent_value_flag', fn( $value, $product ) => null, 10, 2 );
add_filter( 'woocommerce_save_product_cogs_overrides_parent_value_flag', fn( $value, $product ) => null, 10, 2 );
$product->set_cogs_value_overrides_parent( ! $flag_value );
$product->save();

View File

@ -1,10 +1,15 @@
<?php
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
/**
* class WC_REST_Orders_Controller_Tests.
* Orders Controller tests for V3 REST API.
*/
class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
use HPOSToggleTrait;
use CogsAwareUnitTestSuiteTrait;
/**
* Setup our test server, endpoints, and user info.
@ -22,9 +27,11 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
/**
* Get all expected fields.
*
* @param bool $with_cogs_enabled True to return the fields expected when the Cost of Goods Sold feature is enabled.
*/
public function get_expected_response_fields() {
return array(
public function get_expected_response_fields( bool $with_cogs_enabled ) {
$fields = array(
'id',
'parent_id',
'number',
@ -72,14 +79,29 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
'needs_payment',
'needs_processing',
);
if ( $with_cogs_enabled ) {
$fields[] = 'cost_of_goods_sold';
}
return $fields;
}
/**
* @testWith [true]
* [false]
*
* Test that all expected response fields are present.
* Note: This has fields hardcoded intentionally instead of fetching from schema to test for any bugs in schema result. Add new fields manually when added to schema.
*
* @param bool $with_cogs_enabled True to test with the Cost of Goods Sold feature enabled.
*/
public function test_orders_api_get_all_fields() {
$expected_response_fields = $this->get_expected_response_fields();
public function test_orders_api_get_all_fields( bool $with_cogs_enabled ) {
if ( $with_cogs_enabled ) {
$this->enable_cogs_feature();
}
$expected_response_fields = $this->get_expected_response_fields( $with_cogs_enabled );
$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order( $this->user );
$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() ) );
@ -94,10 +116,18 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
}
/**
* @testWith [true]
*
* Test that all fields are returned when requested one by one.
*
* @param bool $with_cogs_enabled True to test with the Cost of Goods Sold feature enabled.
*/
public function test_orders_get_each_field_one_by_one() {
$expected_response_fields = $this->get_expected_response_fields();
public function test_orders_get_each_field_one_by_one( bool $with_cogs_enabled ) {
if ( $with_cogs_enabled ) {
$this->enable_cogs_feature();
}
$expected_response_fields = $this->get_expected_response_fields( $with_cogs_enabled );
$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order( $this->user );
foreach ( $expected_response_fields as $field ) {
@ -255,7 +285,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
*/
public function test_collection_param_include_meta() {
// Create 3 orders.
for ( $i = 1; $i <= 3; $i ++ ) {
for ( $i = 1; $i <= 3; $i++ ) {
$order = new \WC_Order();
$order->add_meta_data( 'test1', 'test1', true );
$order->add_meta_data( 'test2', 'test2', true );
@ -274,7 +304,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'meta_data', $order );
$this->assertEquals( 1, count( $order['meta_data'] ) );
$meta_keys = array_map(
function( $meta_item ) {
function ( $meta_item ) {
return $meta_item->get_data()['key'];
},
$order['meta_data']
@ -288,7 +318,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
*/
public function test_collection_param_include_meta_empty() {
// Create 3 orders.
for ( $i = 1; $i <= 3; $i ++ ) {
for ( $i = 1; $i <= 3; $i++ ) {
$order = new \WC_Order();
$order->add_meta_data( 'test1', 'test1', true );
$order->add_meta_data( 'test2', 'test2', true );
@ -306,7 +336,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
foreach ( $response_data as $order ) {
$this->assertArrayHasKey( 'meta_data', $order );
$meta_keys = array_map(
function( $meta_item ) {
function ( $meta_item ) {
return $meta_item->get_data()['key'];
},
$order['meta_data']
@ -321,7 +351,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
*/
public function test_collection_param_exclude_meta() {
// Create 3 orders.
for ( $i = 1; $i <= 3; $i ++ ) {
for ( $i = 1; $i <= 3; $i++ ) {
$order = new \WC_Order();
$order->add_meta_data( 'test1', 'test1', true );
$order->add_meta_data( 'test2', 'test2', true );
@ -339,7 +369,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
foreach ( $response_data as $order ) {
$this->assertArrayHasKey( 'meta_data', $order );
$meta_keys = array_map(
function( $meta_item ) {
function ( $meta_item ) {
return $meta_item->get_data()['key'];
},
$order['meta_data']
@ -354,7 +384,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
*/
public function test_collection_param_include_meta_override() {
// Create 3 orders.
for ( $i = 1; $i <= 3; $i ++ ) {
for ( $i = 1; $i <= 3; $i++ ) {
$order = new \WC_Order();
$order->add_meta_data( 'test1', 'test1', true );
$order->add_meta_data( 'test2', 'test2', true );
@ -374,7 +404,7 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'meta_data', $order );
$this->assertEquals( 1, count( $order['meta_data'] ) );
$meta_keys = array_map(
function( $meta_item ) {
function ( $meta_item ) {
return $meta_item->get_data()['key'];
},
$order['meta_data']
@ -480,4 +510,76 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
$product = wc_get_product( $product );
$this->assertEquals( 10, $product->get_stock_quantity() );
}
/**
* @testdox The retrieved order data doesn't include Cost of Goods Sold information if the feature is disabled.
*/
public function test_retrieved_order_does_not_include_cogs_info_if_feature_is_disabled() {
$this->enable_cogs_feature();
$this->toggle_cot_feature_and_usage( true );
$order = new WC_Order();
$this->add_product_with_cogs_to_order( $order, 12.34, 2 );
$order->calculate_cogs_total_value();
$order->save();
$this->disable_cogs_feature();
$request = new \WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() );
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$data = $response->get_data();
$this->assertArrayNotHasKey( 'cost_of_goods_sold', $data );
foreach ( $data['line_items'] as $item ) {
$this->assertArrayNotHasKey( 'cost_of_goods_sold', $item );
}
}
/**
* @testdox The retrieved order data includes Cost of Goods Sold information if the feature is enabled.
*/
public function test_retrieved_order_includes_cogs_info_if_feature_is_enabled() {
$this->enable_cogs_feature();
$this->toggle_cot_feature_and_usage( true );
$order = new WC_Order();
$this->add_product_with_cogs_to_order( $order, 12.34, 2 );
$this->add_product_with_cogs_to_order( $order, 56.78, 3 );
$order->calculate_cogs_total_value();
$order->save();
$request = new \WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() );
$response = $this->server->dispatch( $request );
$this->assertEquals( 200, $response->get_status() );
$data = $response->get_data();
$this->assertEquals( 12.34 * 2 + 56.78 * 3, (float) $data['cost_of_goods_sold']['total_value'] );
$items = $data['line_items'];
usort( $items, fn( $a, $b ) => $a['id'] - $b['id'] );
$this->assertEquals( 12.34 * 2, (float) $items[0]['cost_of_goods_sold']['value'] );
$this->assertEquals( 56.78 * 3, (float) $items[1]['cost_of_goods_sold']['value'] );
}
/**
* Add a product order item with a given Cost of Goods Sold to an exising order.
*
* @param WC_Order $order The target order.
* @param float $cogs_value The COGS value of the product.
* @param int $quantity The quantity of the order item.
*/
private function add_product_with_cogs_to_order( WC_Order $order, float $cogs_value, int $quantity ) {
$product = WC_Helper_Product::create_simple_product();
$product->set_cogs_value( $cogs_value );
$product->save();
$item = new WC_Order_Item_Product();
$item->set_product( $product );
$item->set_quantity( $quantity );
$item->save();
$order->add_item( $item );
}
}

View File

@ -4,9 +4,11 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\DataStores\Orders;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableQuery;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
@ -33,6 +35,7 @@ use WC_Tests_Webhook_Functions;
*/
class OrdersTableDataStoreTests extends \HposTestCase {
use HPOSToggleTrait;
use CogsAwareUnitTestSuiteTrait;
/**
* Original timezone before this test started.
@ -99,8 +102,11 @@ class OrdersTableDataStoreTests extends \HposTestCase {
update_option( 'timezone_string', $this->original_time_zone );
$this->toggle_cot_feature_and_usage( $this->cot_state );
$this->clean_up_cot_setup();
$this->disable_cogs_feature();
remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' );
remove_all_filters( 'woocommerce_load_order_cogs_value' );
remove_all_filters( 'woocommerce_save_order_cogs_value' );
parent::tearDown();
}
@ -359,7 +365,6 @@ class OrdersTableDataStoreTests extends \HposTestCase {
foreach ( $props_to_update as $prop => $value ) {
$this->assertEquals( $order->{"get_$prop"}( 'edit' ), $value );
}
}
/**
@ -863,7 +868,6 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$query_args['orderby']['color_meta'] = 'ASC';
$q = new OrdersTableQuery( $query_args );
$this->assertEquals( $q->orders, array( $order2->get_id(), $order1->get_id() ) );
}
/**
@ -936,7 +940,6 @@ class OrdersTableDataStoreTests extends \HposTestCase {
)
);
$this->assertEquals( 0, $query->found_orders );
}
/**
@ -1108,7 +1111,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$test_orders = array();
$this->assertEquals( 0, ( new OrdersTableQuery() )->found_orders, 'We initially have zero orders within our custom order tables.' );
for ( $i = 0; $i < 30; $i ++ ) {
for ( $i = 0; $i < 30; $i++ ) {
$order = new WC_Order();
$this->switch_data_store( $order, $this->sut );
$order->save();
@ -1155,7 +1158,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
public function test_cot_query_count() {
$this->assertEquals( 0, ( new OrdersTableQuery() )->found_orders, 'We initially have zero orders within our custom order tables.' );
for ( $i = 0; $i < 30; $i ++ ) {
for ( $i = 0; $i < 30; $i++ ) {
$order = new WC_Order();
$this->switch_data_store( $order, $this->sut );
if ( 0 === $i % 2 ) {
@ -2156,7 +2159,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$order_id = $order->get_id();
$should_sync_callable = function( $order ) {
$should_sync_callable = function ( $order ) {
return $this->should_sync_order( $order );
};
@ -2175,7 +2178,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
public function test_child_orders_are_promoted_when_parent_is_deleted_if_order_type_is_hierarchical() {
$this->register_legacy_proxy_function_mocks(
array(
'is_post_type_hierarchical' => function( $post_type ) {
'is_post_type_hierarchical' => function ( $post_type ) {
return 'shop_order' === $post_type || is_post_type_hierarchical( $post_type );
},
)
@ -2202,7 +2205,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
public function test_child_orders_are_promoted_when_parent_is_deleted_if_order_type_is_not_hierarchical() {
$this->register_legacy_proxy_function_mocks(
array(
'is_post_type_hierarchical' => function( $post_type ) {
'is_post_type_hierarchical' => function ( $post_type ) {
return 'shop_order' === $post_type ? false : is_post_type_hierarchical( $post_type );
},
)
@ -2368,7 +2371,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$this->reset_legacy_proxy_mocks();
$this->register_legacy_proxy_function_mocks(
array(
'is_post_type_hierarchical' => function( $post_type ) {
'is_post_type_hierarchical' => function ( $post_type ) {
return 'shop_order' === $post_type ? true : is_post_type_hierarchical( $post_type );
},
)
@ -2408,7 +2411,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
private function allow_current_user_to_delete_posts() {
$this->register_legacy_proxy_function_mocks(
array(
'current_user_can' => function( $capability ) {
'current_user_can' => function ( $capability ) {
return 'delete_posts' === $capability ? true : current_user_can( $capability );
},
)
@ -2552,7 +2555,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_logger' => function() use ( $fake_logger ) {
'wc_get_logger' => function () use ( $fake_logger ) {
return $fake_logger;
},
)
@ -2790,7 +2793,8 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$new_count = 0;
$update_count = 0;
$callback = function( $order_id ) use ( &$new_count, &$update_count ) {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
$callback = function ( $order_id ) use ( &$new_count, &$update_count ) {
$new_count += 'woocommerce_new_order' === current_action() ? 1 : 0;
$update_count += 'woocommerce_update_order' === current_action() ? 1 : 0;
};
@ -2964,7 +2968,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$order->set_discount_total( '1.23' );
$order->save();
$call_protected = function( $ids ) {
$call_protected = function ( $ids ) {
return $this->get_order_data_for_ids( $ids );
};
@ -3210,7 +3214,8 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$this->register_legacy_proxy_function_mocks(
array(
'current_time' => function( $type, $gmt ) use ( &$current_time_called, $now ) {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
'current_time' => function ( $type, $gmt ) use ( &$current_time_called, $now ) {
$current_time_called = true;
return $now;
},
@ -3357,7 +3362,7 @@ class OrdersTableDataStoreTests extends \HposTestCase {
// phpcs:enable Squiz.Commenting
$this->register_legacy_proxy_function_mocks(
array(
'wc_get_logger' => function() use ( $fake_logger ) {
'wc_get_logger' => function () use ( $fake_logger ) {
return $fake_logger;
},
)
@ -3445,7 +3450,6 @@ class OrdersTableDataStoreTests extends \HposTestCase {
$other_counts = OrderUtil::get_count_for_type( 'shop_something' );
$this->assertEquals( 0, array_pop( $other_counts ) );
}
/**
@ -3540,4 +3544,196 @@ class OrdersTableDataStoreTests extends \HposTestCase {
remove_action( 'woocommerce_new_order', $callback );
}
/**
* @testdox Saving an order does not persist its Cost of Goods Sold total value if the feature is disabled.
*/
public function test_saving_order_does_not_save_cogs_value_if_cogs_disabled() {
$this->toggle_cot_feature_and_usage( true );
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Abstract_Order::set_cogs_total_value' );
$meta_store = wc_get_container()->get( OrdersTableDataStoreMeta::class );
$order = new WC_Order();
$order->set_cogs_total_value( 12.34 );
$order->save();
$meta_objects = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
$this->assertFalse( $meta_objects );
}
/**
* @testdox Saving an order does not persist its Cost of Goods Sold total value if the feature is enabled but the order doesn't manage it.
*/
public function test_saving_order_does_not_save_cogs_value_if_order_has_no_cogs() {
$this->toggle_cot_feature_and_usage( true );
$this->enable_cogs_feature();
$meta_store = wc_get_container()->get( OrdersTableDataStoreMeta::class );
// phpcs:disable Squiz.Commenting
$order = new class() extends WC_Order {
public function has_cogs(): bool {
return false;
}
};
// phpcs:enable Squiz.Commenting
$order->set_cogs_total_value( 12.34 );
$order->save();
$meta_objects = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
$this->assertFalse( $meta_objects );
}
/**
* @testdox Saving an order persists its Cost of Goods Sold total value if the feature is enabled and the order manages it.
*/
public function test_saving_order_saves_cogs_value_if_not_zero_and_cogs_enabled() {
$this->toggle_cot_feature_and_usage( true );
$this->enable_cogs_feature();
$meta_store = wc_get_container()->get( OrdersTableDataStoreMeta::class );
$order = new WC_Order();
$order->set_cogs_total_value( 12.34 );
$order->save();
$meta_objects = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
$this->assertEquals( 12.34, (float) $meta_objects[0]->meta_value );
$order->set_cogs_total_value( 56.78 );
$order->save();
$meta_objects = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
$this->assertEquals( 56.78, (float) $meta_objects[0]->meta_value );
$order->set_cogs_total_value( 0 );
$order->save();
$meta_objects = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
$this->assertFalse( $meta_objects );
}
/**
* @testdox Loading an order reads its Cost of Goods Sold value from the database if the feature is enabled and the order manages it.
*
* @testWith [true, false]
* [false, true]
* [true, true]
* [false, false]
*
* @param bool $cogs_enabled True if the feature is enabled.
* @param bool $order_has_cogs True if the order manages COGS.
*/
public function test_loading_order_loads_cogs_value_if_cogs_enabled( bool $cogs_enabled, bool $order_has_cogs ) {
$this->toggle_cot_feature_and_usage( true );
if ( $cogs_enabled ) {
$this->enable_cogs_feature();
} elseif ( $order_has_cogs ) {
$this->expect_doing_it_wrong_cogs_disabled( 'WC_Abstract_Order::get_cogs_total_value' );
}
$meta_store = wc_get_container()->get( OrdersTableDataStoreMeta::class );
$order = new WC_Order();
$order->save();
$saved_meta = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
if ( $saved_meta ) {
$meta_store->delete_meta( $order, $saved_meta[0] );
}
$meta = new \WC_Meta_Data();
$meta->key = '_cogs_total_value';
$meta->value = '12.34';
$meta_store->add_meta( $order, $meta );
if ( $order_has_cogs ) {
$order2 = wc_get_order( $order->get_id() );
} else {
// phpcs:disable Squiz.Commenting
$order2 = new class($order->get_id()) extends WC_Order {
public function has_cogs(): bool {
return false;
}
};
// phpcs:enable Squiz.Commenting
}
$this->assertEquals( ( $cogs_enabled && $order_has_cogs ) ? 12.34 : 0, $order2->get_cogs_total_value() );
}
/**
* @testdox It's possible to modify the Cost of Goods Sold value that gets loaded from the database for an order using the 'woocommerce_load_order_cogs_value' filter.
*/
public function test_loaded_cogs_value_can_be_modified_via_filter() {
$received_filter_cogs_value = null;
$received_filter_item = null;
$this->toggle_cot_feature_and_usage( true );
$this->enable_cogs_feature();
$order = new WC_Order();
$order->set_cogs_total_value( 12.34 );
$order->save();
add_filter(
'woocommerce_load_order_cogs_value',
function ( $cogs_value, $item ) use ( &$received_filter_cogs_value, &$received_filter_item ) {
$received_filter_cogs_value = $cogs_value;
$received_filter_item = $item;
return 56.78;
},
10,
2
);
$order2 = wc_get_order( $order->get_id() );
$this->assertEquals( 12.34, $received_filter_cogs_value );
$this->assertSame( $order2, $received_filter_item );
$this->assertEquals( 56.78, $order2->get_cogs_total_value() );
}
/**
* @testdox It's possible to modify the Cost of Goods Sold value that gets persisted for an order using the 'woocommerce_save_order_cogs_value' filter, returning null suppresses the saving.
*
* @testWith [56.78, "56.78"]
* [null, "12.34"]
*
* @param mixed $filter_return_value The value that the filter will return.
* @param string $expected_saved_value The value that is expected to be persisted after the save attempt.
*/
public function test_saved_cogs_value_can_be_altered_via_filter_with_null_meaning_dont_save( $filter_return_value, string $expected_saved_value ) {
$received_filter_cogs_value = null;
$received_filter_item = null;
$this->toggle_cot_feature_and_usage( true );
$this->enable_cogs_feature();
$order = new WC_Order();
$order->set_cogs_total_value( 12.34 );
$order->save();
add_filter(
'woocommerce_save_order_cogs_value',
function ( $cogs_value, $item ) use ( &$received_filter_cogs_value, &$received_filter_item, $filter_return_value ) {
$received_filter_cogs_value = $cogs_value;
$received_filter_item = $item;
return $filter_return_value;
},
10,
2
);
$order->set_cogs_total_value( 56.78 );
$order->save();
$this->assertEquals( 56.78, $received_filter_cogs_value );
$this->assertSame( $order, $received_filter_item );
$meta_store = wc_get_container()->get( OrdersTableDataStoreMeta::class );
$meta_objects = $meta_store->get_metadata_by_key( $order, '_cogs_total_value' );
$this->assertEquals( $expected_saved_value, (float) $meta_objects[0]->meta_value );
}
}

View File

@ -1,7 +1,6 @@
<?php
declare( strict_types = 1 );
// phpcs:disable Universal.Namespaces.DisallowCurlyBraceSyntax.Forbidden -- need to override filter_input
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- same
// phpcs:disable Universal.Namespaces.OneDeclarationPerFile.MultipleFound -- same
@ -9,6 +8,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use Automattic\WooCommerce\Utilities\StringUtil;
use WC_Rate_Limiter;
use WC_Cache_Helper;
/**
@ -499,41 +499,63 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
* @return array[] Test cases.
*/
public function is_third_party_error_provider() {
$wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH );
$wp_includes_dir = StringUtil::normalize_local_path_slashes( ABSPATH . WPINC );
$wp_admin_dir = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' );
return array(
array( 'Fatal error in ' . WC_ABSPATH . 'file.php', array(), false ),
array( 'Fatal error in /wp-content/file.php', array(), false ),
array( 'Fatal error in /wp-content/file.php', array( 'source' => 'fatal-errors' ), false ),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
'WooCommerce error message' => array(
'Error in ' . $wc_plugin_dir . 'includes/class-wc-cart.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( '/wp-content/plugins/3rd-plugin/file.php', WC_ABSPATH . 'file.php' ),
'backtrace' => array(),
),
false,
),
array(
'Fatal error in /wp-content/plugins/woocommerce-3rd-plugin/file.php',
'Third-party error message' => array(
'Error in /plugins/some-other-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( WP_PLUGIN_DIR . 'woocommerce-3rd-plugin/file.php' ),
'backtrace' => array(),
),
true,
),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
'WooCommerce backtrace' => array(
'Some error message',
array(
'source' => 'fatal-errors',
'backtrace' => array( WP_PLUGIN_DIR . '3rd-plugin/file.php' ),
'backtrace' => array(
$wp_includes_dir . 'functions.php',
$wc_plugin_dir . 'includes/class-wc-cart.php',
'/plugins/some-other-plugin/file.php',
),
),
false,
),
'Third-party backtrace' => array(
'Some error message',
array(
'source' => 'fatal-errors',
'backtrace' => array(
$wp_includes_dir . 'functions.php',
$wp_admin_dir . 'admin.php',
'/plugins/some-other-plugin/file.php',
),
),
true,
),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
'Non-fatal-errors source' => array(
'Some error message',
array(
'source' => 'fatal-errors',
'backtrace' => array( array( 'file' => WP_PLUGIN_DIR . '3rd-plugin/file.php' ) ),
'source' => 'other-source',
'backtrace' => array(),
),
true,
false,
),
'Missing backtrace' => array(
'Some error message',
array( 'source' => 'fatal-errors' ),
false,
),
);
}
@ -561,6 +583,111 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging {
$this->assertEquals( $expected, $result );
}
/**
* @testdox redact_user_data method correctly redacts sensitive information
* @dataProvider redact_user_data_provider
*
* @param string $input The input string containing sensitive data.
* @param string $expected The expected output with redacted data.
*/
public function test_redact_user_data( $input, $expected ) {
$result = $this->invoke_private_method( $this->sut, 'redact_user_data', array( $input ) );
$this->assertEquals( $expected, $result );
}
/**
* Data provider for test_redact_user_data.
*
* @return array[] Test cases with input strings and expected redacted outputs.
*/
public function redact_user_data_provider() {
return array(
'email address' => array(
'input' => 'User email is john.doe@example.com',
'expected' => 'User email is [redacted_email]',
),
'complex email address' => array(
'input' => 'Email: test.user+label@sub-domain.example.co.uk',
'expected' => 'Email: [redacted_email]',
),
'international phone with parentheses' => array(
'input' => 'Phone: +1 (123) 456 7890',
'expected' => 'Phone: [redacted_phone]',
),
'international phone with dashes' => array(
'input' => 'Contact at +44-123-456-7890',
'expected' => 'Contact at [redacted_phone]',
),
'simple phone number' => array(
'input' => 'Call 1234567890',
'expected' => 'Call [redacted_phone]',
),
'formatted phone number' => array(
'input' => 'Phone: (123) 456-7890',
'expected' => 'Phone: [redacted_phone]',
),
'should not match short number' => array(
'input' => 'Order #123 status',
'expected' => 'Order #123 status',
),
'should not match medium number' => array(
'input' => 'Product 12345',
'expected' => 'Product 12345',
),
'IP address' => array(
'input' => 'User IP: 192.168.1.1',
'expected' => 'User IP: [redacted_ip]',
),
'credit card number spaced' => array(
'input' => 'Card: 4111 1111 1111 1111',
'expected' => 'Card: [redacted_credit_card]',
),
'mixed sensitive data' => array(
'input' => 'Contact: user@example.com, Tel: +1-234-567-8900, IP: 192.168.0.1, Card: 4111 1111 1111 1111',
'expected' => 'Contact: [redacted_email], Tel: [redacted_phone], IP: [redacted_ip], Card: [redacted_credit_card]',
),
'numbers in text' => array(
'input' => 'Order #123 had 456 items costing $789',
'expected' => 'Order #123 had 456 items costing $789',
),
'generic api key' => array(
'input' => 'API Key: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0',
'expected' => 'API Key: [redacted_api_key]',
),
'hex api key' => array(
'input' => 'Key: 1234567890abcdef1234567890abcdef',
'expected' => 'Key: [redacted_api_key]',
),
'segmented api key' => array(
'input' => 'Access Key: ABCD-1234-EFGH-5678',
'expected' => 'Access Key: [redacted_api_key]',
),
'multiple api keys' => array(
'input' => 'Keys: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0, ABCD-1234-EFGH-5678, sk_fakekey0123456789abcdefg',
'expected' => 'Keys: [redacted_api_key], [redacted_api_key], [redacted_api_key]',
),
);
}
/**
* @testdox sanitize method applies custom sanitization filter
*/
public function test_sanitize_with_custom_filter() {
add_filter(
'woocommerce_remote_logger_sanitized_content',
function ( $sanitized ) {
return str_replace( 'test', 'filtered', $sanitized );
},
10,
2
);
$message = WC_ABSPATH . 'includes/class-wc-test.php on line 123';
$expected = './woocommerce/includes/class-wc-filtered.php on line 123';
$result = $this->invoke_private_method( $this->sut, 'sanitize', array( $message ) );
$this->assertEquals( $expected, $result );
}
/**
* Setup common conditions for remote logging tests.
*

View File

@ -0,0 +1,46 @@
<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use WC_Unit_Test_Case;
/**
* Class PaymentInfoTest.
*/
class PaymentInfoTest extends WC_Unit_Test_Case {
/**
*
*/
const ENCODED_VISA_CARD_ICON = 'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgdmlld0JveD0iMCAwIDc1MCA0NzEiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sbnM6c2tldGNoPSJodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2gvbnMiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIG1lZXQiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCAzLjMuMSAoMTIwMDUpIC0gaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoIC0tPgogICAgPHRpdGxlPlNsaWNlIDE8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZGVmcz48L2RlZnM+CiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4KICAgICAgICA8ZyBpZD0idmlzYSIgc2tldGNoOnR5cGU9Ik1TTGF5ZXJHcm91cCI+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtMSIgZmlsbD0iIzBFNDU5NSIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCIgeD0iMCIgeT0iMCIgd2lkdGg9Ijc1MCIgaGVpZ2h0PSI0NzEiIHJ4PSI0MCI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNMjc4LjE5NzUsMzM0LjIyNzUgTDMxMS41NTg1LDEzOC40NjU1IEwzNjQuOTE3NSwxMzguNDY1NSBMMzMxLjUzMzUsMzM0LjIyNzUgTDI3OC4xOTc1LDMzNC4yMjc1IEwyNzguMTk3NSwzMzQuMjI3NSBaIiBpZD0iU2hhcGUiIGZpbGw9IiNGRkZGRkYiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTUyNC4zMDc1LDE0Mi42ODc1IEM1MTMuNzM1NSwxMzguNzIxNSA0OTcuMTcxNSwxMzQuNDY1NSA0NzYuNDg0NSwxMzQuNDY1NSBDNDIzLjc2MDUsMTM0LjQ2NTUgMzg2LjYyMDUsMTYxLjAxNjUgMzg2LjMwNDUsMTk5LjA2OTUgQzM4Ni4wMDc1LDIyNy4xOTg1IDQxMi44MTg1LDI0Mi44OTA1IDQzMy4wNTg1LDI1Mi4yNTQ1IEM0NTMuODI3NSwyNjEuODQ5NSA0NjAuODEwNSwyNjcuOTY5NSA0NjAuNzExNSwyNzYuNTM3NSBDNDYwLjU3OTUsMjg5LjY1OTUgNDQ0LjEyNTUsMjk1LjY1NDUgNDI4Ljc4ODUsMjk1LjY1NDUgQzQwNy40MzE1LDI5NS42NTQ1IDM5Ni4wODU1LDI5Mi42ODc1IDM3OC41NjI1LDI4NS4zNzg1IEwzNzEuNjg2NSwyODIuMjY2NSBMMzY0LjE5NzUsMzI2LjA5MDUgQzM3Ni42NjA1LDMzMS41NTQ1IDM5OS43MDY1LDMzNi4yODk1IDQyMy42MzU1LDMzNi41MzQ1IEM0NzkuNzI0NSwzMzYuNTM0NSA1MTYuMTM2NSwzMTAuMjg3NSA1MTYuNTUwNSwyNjkuNjUyNSBDNTE2Ljc1MTUsMjQ3LjM4MzUgNTAyLjUzNTUsMjMwLjQzNTUgNDcxLjc1MTUsMjE2LjQ2NDUgQzQ1My4xMDA1LDIwNy40MDg1IDQ0MS42Nzg1LDIwMS4zNjU1IDQ0MS43OTk1LDE5Mi4xOTU1IEM0NDEuNzk5NSwxODQuMDU4NSA0NTEuNDY3NSwxNzUuMzU3NSA0NzIuMzU2NSwxNzUuMzU3NSBDNDg5LjgwNTUsMTc1LjA4NjUgNTAyLjQ0NDUsMTc4Ljg5MTUgNTEyLjI5MjUsMTgyLjg1NzUgTDUxNy4wNzQ1LDE4NS4xMTY1IEw1MjQuMzA3NSwxNDIuNjg3NSIgaWQ9InBhdGgxMyIgZmlsbD0iI0ZGRkZGRiIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNNjYxLjYxNDUsMTM4LjQ2NTUgTDYyMC4zODM1LDEzOC40NjU1IEM2MDcuNjEwNSwxMzguNDY1NSA1OTguMDUyNSwxNDEuOTUxNSA1OTIuNDQyNSwxNTQuNjk5NSBMNTEzLjE5NzUsMzM0LjEwMjUgTDU2OS4yMjg1LDMzNC4xMDI1IEM1NjkuMjI4NSwzMzQuMTAyNSA1NzguMzkwNSwzMDkuOTgwNSA1ODAuNDYyNSwzMDQuNjg0NSBDNTg2LjU4NTUsMzA0LjY4NDUgNjQxLjAxNjUsMzA0Ljc2ODUgNjQ4Ljc5ODUsMzA0Ljc2ODUgQzY1MC4zOTQ1LDMxMS42MjE1IDY1NS4yOTA1LDMzNC4xMDI1IDY1NS4yOTA1LDMzNC4xMDI1IEw3MDQuODAyNSwzMzQuMTAyNSBMNjYxLjYxNDUsMTM4LjQ2NTUgTDY2MS42MTQ1LDEzOC40NjU1IFogTTU5Ni4xOTc1LDI2NC44NzI1IEM2MDAuNjEwNSwyNTMuNTkzNSA2MTcuNDU2NSwyMTAuMTQ5NSA2MTcuNDU2NSwyMTAuMTQ5NSBDNjE3LjE0MTUsMjEwLjY3MDUgNjIxLjgzNjUsMTk4LjgxNTUgNjI0LjUzMTUsMTkxLjQ2NTUgTDYyOC4xMzg1LDIwOC4zNDM1IEM2MjguMTM4NSwyMDguMzQzNSA2MzguMzU1NSwyNTUuMDcyNSA2NDAuNDkwNSwyNjQuODcxNSBMNTk2LjE5NzUsMjY0Ljg3MTUgTDU5Ni4xOTc1LDI2NC44NzI1IEw1OTYuMTk3NSwyNjQuODcyNSBaIiBpZD0iUGF0aCIgZmlsbD0iI0ZGRkZGRiIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMjMyLjkwMjUsMTM4LjQ2NTUgTDE4MC42NjI1LDI3MS45NjA1IEwxNzUuMDk2NSwyNDQuODMxNSBDMTY1LjM3MTUsMjEzLjU1NzUgMTM1LjA3MTUsMTc5LjY3NTUgMTAxLjE5NzUsMTYyLjcxMjUgTDE0OC45NjQ1LDMzMy45MTU1IEwyMDUuNDE5NSwzMzMuODUwNSBMMjg5LjQyMzUsMTM4LjQ2NTUgTDIzMi45MDI1LDEzOC40NjU1IiBpZD0icGF0aDE2IiBmaWxsPSIjRkZGRkZGIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMzEuOTE5NSwxMzguNDY1NSBMNDUuODc4NSwxMzguNDY1NSBMNDUuMTk3NSwxNDIuNTM4NSBDMTEyLjEzNjUsMTU4Ljc0MjUgMTU2LjQyOTUsMTk3LjkwMTUgMTc0LjgxNTUsMjQ0Ljk1MjUgTDE1Ni4xMDY1LDE1NC45OTI1IEMxNTIuODc2NSwxNDIuNTk2NSAxNDMuNTA4NSwxMzguODk3NSAxMzEuOTE5NSwxMzguNDY1NSIgaWQ9InBhdGgxOCIgZmlsbD0iI0YyQUUxNCIgc2tldGNoOnR5cGU9Ik1TU2hhcGVHcm91cCI+PC9wYXRoPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+';
/**
* @testdox The `get_card_info` method should return an associative array with specific keys when an order has
* payment card info available.
*/
public function test_get_card_info_wcpay_online(): void {
$order = OrderHelper::create_order();
Constants::set_constant( 'WCPAY_DEV_MODE', true ); // Enables use of order meta for providing payment details.
$order->set_payment_method( 'woocommerce_payments' );
$order->add_meta_data(
'_wcpay_payment_details',
'{"card":{"amount_authorized":4500,"authorization_code":null,"brand":"visa","checks":{"address_line1_check":"pass","address_postal_code_check":"pass","cvc_check":"pass"},"country":"US","description":"Visa Classic","exp_month":12,"exp_year":2034,"extended_authorization":{"status":"disabled"},"fingerprint":"redacted","funding":"credit","iin":"424242","incremental_authorization":{"status":"unavailable"},"installments":null,"issuer":"Unit Test","last4":"4242","mandate":null,"multicapture":{"status":"unavailable"},"network":"visa","network_token":{"used":false},"overcapture":{"maximum_amount_capturable":4500,"status":"unavailable"},"three_d_secure":null,"wallet":null},"type":"card"}',
true
);
$order->save();
$result = $order->get_payment_card_info();
$this->assertArrayHasKey( 'payment_method', $result );
$this->assertEquals( 'woocommerce_payments', $result['payment_method'] );
$this->assertArrayHasKey( 'brand', $result );
$this->assertEquals( 'visa', $result['brand'] );
$this->assertArrayHasKey( 'icon', $result );
$this->assertEquals( self::ENCODED_VISA_CARD_ICON, $result['icon'] );
$this->assertArrayHasKey( 'last4', $result );
$this->assertEquals( '4242', $result['last4'] );
}
}

File diff suppressed because it is too large Load Diff