Skip to content

Commit 33d11d6

Browse files
annemirasolCopilotdiegocurbelo
authored
ECE: add support for custom fields in classic checkout (#4424)
* Pass list of custom checkout fields * Add the custom data under extensions * Register the extended data and save it * Defer logic to hook for consistency with normal checkout * Add comments * Add support for radio buttons * Perform basic sanitization based on field type * Add action for custom data validation * Update action names * Fix logic for getting standard checkout fields * Add unit test * Add changelog and readme entries * Specify checkout form name * Respect keys_only param even for block checkout * Remove TODO and add comment * Display all error messages instead of just the first. Co-authored-by: Copilot <[email protected]> * Add more comments * Rename exception code * Remove keys_only support * Use wc_stripe prefix for action Co-authored-by: Diego Curbelo <[email protected]> * Use wc_stripe prefix for action Co-authored-by: Diego Curbelo <[email protected]> * Use wc_stripe prefix for action Co-authored-by: Diego Curbelo <[email protected]> * Validate data after json decode --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Diego Curbelo <[email protected]>
1 parent b7e0cfc commit 33d11d6

File tree

6 files changed

+356
-15
lines changed

6 files changed

+356
-15
lines changed

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
* Tweak - Remove Payment Method Configurations fallback cache
4343
* Tweak - Update deprecation notice message to specify that legacy checkout experience has been deprecated since version 9.6.0
4444
* Update - Remove legacy checkout checkbox from settings
45+
* Update - Express Checkout: introduce new WP actions for supporting custom checkout fields for classic, shortcode-based checkout
4546
* Fix - Fix payment processing for $0 subscription with recurring coupon
4647
* Dev - Add e2e tests to cover Affirm purchase flow
4748

client/entrypoints/express-checkout/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { getStripeServerData } from 'wcstripe/stripe-utils';
2828
import { getAddToCartVariationParams } from 'wcstripe/utils';
2929
import 'wcstripe/express-checkout/compatibility/wc-order-attribution';
30+
import 'wcstripe/express-checkout/compatibility/classic-checkout-custom-fields';
3031
import 'wcstripe/express-checkout/compatibility/wc-product-page';
3132
import './styles.scss';
3233
import {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* This file is for passing custom checkout field data to Store API, for when
3+
* using express checkout with classic checkout.
4+
*
5+
* It extracts the data from the form, and passes the data under extensions, using
6+
* the wc-stripe/express-checkout namespace.
7+
*/
8+
import { addFilter } from '@wordpress/hooks';
9+
import { getExpressCheckoutData } from 'wcstripe/express-checkout/utils';
10+
11+
addFilter(
12+
'wcstripe.express-checkout.cart-place-order-extension-data',
13+
'automattic/wcstripe/express-checkout',
14+
( extensionData ) => {
15+
// List of fields we are interested in.
16+
const customCheckoutFields = getExpressCheckoutData(
17+
'custom_checkout_fields'
18+
);
19+
20+
if ( ! customCheckoutFields ) {
21+
return extensionData;
22+
}
23+
24+
// Extract the data from the checkout form.
25+
const customCheckoutFieldsData = {};
26+
Object.keys( customCheckoutFields ).forEach( ( field ) => {
27+
const formElements = document.querySelectorAll(
28+
`form[name="checkout"] [name="${ field }"]`
29+
);
30+
if ( ! formElements || formElements.length === 0 ) {
31+
return;
32+
}
33+
34+
formElements.forEach( ( formElement ) => {
35+
if ( formElement.type === 'checkbox' ) {
36+
if ( formElement.checked ) {
37+
customCheckoutFieldsData[ field ] = 1;
38+
}
39+
} else if ( formElement.type === 'radio' ) {
40+
if ( formElement.checked ) {
41+
customCheckoutFieldsData[ field ] = formElement.value;
42+
}
43+
} else {
44+
customCheckoutFieldsData[ field ] = formElement.value;
45+
}
46+
} );
47+
} );
48+
49+
return {
50+
...extensionData,
51+
'wc-stripe/express-checkout': {
52+
custom_checkout_data: JSON.stringify(
53+
customCheckoutFieldsData
54+
),
55+
},
56+
};
57+
}
58+
);

includes/payment-methods/class-wc-stripe-express-checkout-element.php

Lines changed: 217 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
use Automattic\WooCommerce\Blocks\Package;
1414
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
15+
use Automattic\WooCommerce\StoreApi\StoreApi;
16+
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
17+
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
1518

1619
/**
1720
* WC_Stripe_Express_Checkout_Element class.
@@ -110,6 +113,150 @@ public function init() {
110113
add_filter( 'woocommerce_cart_needs_shipping_address', [ $this, 'filter_cart_needs_shipping_address' ], 11, 1 );
111114

112115
add_action( 'before_woocommerce_pay_form', [ $this, 'localize_pay_for_order_page_scripts' ] );
116+
117+
$this->setup_custom_checkout_data();
118+
}
119+
120+
/**
121+
* Perform necessary setup steps for supporting custom checkout fields in express checkout,
122+
* including registering space for the data in the Store API,
123+
* and hooking into the action that will let us process the data and update the order.
124+
*
125+
* @return void
126+
*/
127+
private function setup_custom_checkout_data() {
128+
// Register our express checkout data as extended data, which will hold
129+
// custom checkout fielddata if present.
130+
$extend_schema = StoreApi::container()->get( ExtendSchema::class );
131+
$extend_schema->register_endpoint_data(
132+
[
133+
'endpoint' => CheckoutSchema::IDENTIFIER,
134+
'namespace' => 'wc-stripe/express-checkout',
135+
'schema_callback' => [ $this, 'get_custom_checkout_data_schema' ],
136+
]
137+
);
138+
139+
// Update order based on extended data.
140+
add_action(
141+
'woocommerce_store_api_checkout_update_order_from_request',
142+
[ $this, 'process_custom_checkout_data' ],
143+
10,
144+
2
145+
);
146+
}
147+
148+
/**
149+
* Allow third-party to validate and add custom checkout data to
150+
* express checkout orders for stores using classic, shortcode-based checkout.
151+
*
152+
* @param WC_Order $order The order to add custom checkout data to.
153+
* @param WP_REST_Request $request The request object.
154+
* @return void
155+
*/
156+
public function process_custom_checkout_data( $order, $request ) {
157+
$custom_checkout_data = $this->get_custom_checkout_data_from_request( $request );
158+
if ( empty( $custom_checkout_data ) ) {
159+
return;
160+
}
161+
162+
$errors = new WP_Error();
163+
/**
164+
* Allow third-party plugins to validate custom checkout data for express checkout orders.
165+
*
166+
* To be used as a stand-in for the `woocommerce_after_checkout_validation` action.
167+
*
168+
* @since 9.6.0
169+
*
170+
* @param array $custom_checkout_data The custom checkout data.
171+
* @param WP_Error $errors The WP_Error object, for adding errors when validation fails.
172+
*/
173+
do_action( 'wc_stripe_express_checkout_after_checkout_validation', $custom_checkout_data, $errors );
174+
175+
if ( $errors->has_errors() ) {
176+
$error_messages = implode( "\n", $errors->get_error_messages() );
177+
throw new WC_Data_Exception( 'wc_stripe_express_checkout_invalid_data', $error_messages );
178+
}
179+
180+
/**
181+
* Allow third-party plugins to add custom checkout data for express checkout orders.
182+
*
183+
* To be used as a stand-in for the `woocommerce_checkout_update_order_meta` action.
184+
*
185+
* @since 9.6.0
186+
*
187+
* @param integer $order_id The order ID.
188+
* @param array $custom_checkout_data The custom checkout data.
189+
*/
190+
do_action( 'wc_stripe_express_checkout_update_order_meta', $order->get_id(), $custom_checkout_data );
191+
}
192+
193+
/**
194+
* Get custom checkout data from the request object.
195+
*
196+
* To support custom fields in express checkout and classic checkout,
197+
* we pass custom data as extended data, i.e. under extensions.
198+
*
199+
* @param WP_REST_Request $request The request object.
200+
* @return array Custom checkout data.
201+
*/
202+
private function get_custom_checkout_data_from_request( $request ) {
203+
$extensions = $request->get_param( 'extensions' );
204+
if ( empty( $extensions ) ) {
205+
return [];
206+
}
207+
208+
$custom_checkout_data_json = $extensions['wc-stripe/express-checkout']['custom_checkout_data'] ?? '';
209+
if ( empty( $custom_checkout_data_json ) ) {
210+
return [];
211+
}
212+
213+
$custom_checkout_data = json_decode( $custom_checkout_data_json, true );
214+
if ( empty( $custom_checkout_data ) || ! is_array( $custom_checkout_data ) ) {
215+
return [];
216+
}
217+
218+
// Perform basic sanitization before passing to actions.
219+
$sanitized_custom_checkout_data = [];
220+
$custom_checkout_fields = $this->get_custom_checkout_fields();
221+
foreach ( $custom_checkout_data as $key => $value ) {
222+
$field_type = $custom_checkout_fields[ $key ]['type'] ?? 'text';
223+
$sanitized_key = sanitize_text_field( $key );
224+
$sanitized_value = $this->get_sanitized_value( $value, $field_type );
225+
$sanitized_custom_checkout_data[ $sanitized_key ] = $sanitized_value;
226+
}
227+
228+
return $sanitized_custom_checkout_data;
229+
}
230+
231+
/**
232+
* Perform basic sanitization on custom checkout field values, based on the field type.
233+
*
234+
* @param string $value The value to sanitize.
235+
* @param string $type The type of the field.
236+
* @return string The sanitized value.
237+
*/
238+
private function get_sanitized_value( $value, $type ) {
239+
if ( 'textarea' === $type ) {
240+
return sanitize_textarea_field( $value );
241+
} elseif ( 'email' === $type ) {
242+
return sanitize_email( $value );
243+
}
244+
245+
return sanitize_text_field( $value );
246+
}
247+
/**
248+
* Get custom checkout data schema.
249+
*
250+
* @return array Custom checkout data schema.
251+
*/
252+
public function get_custom_checkout_data_schema() {
253+
return [
254+
'custom_checkout_data' => [
255+
'type' => [ 'string', 'null' ],
256+
'context' => [],
257+
'arg_options' => [],
258+
],
259+
];
113260
}
114261

115262
/**
@@ -229,32 +376,87 @@ public function javascript_params() {
229376

230377
/**
231378
* Retrieve custom checkout field IDs.
232-
* TODO: Currently, we only support custom checkout fields for block checkout.
233-
* We need to add support for classic checkout custom fields.
234379
*
235380
* @return array Custom checkout field IDs.
236381
*/
237382
public function get_custom_checkout_fields() {
238-
try {
239-
$checkout_fields = Package::container()->get( CheckoutFields::class );
240-
if ( ! $checkout_fields instanceof CheckoutFields ) {
383+
// Block checkout page
384+
if ( has_block( 'woocommerce/checkout' ) ) {
385+
try {
386+
$checkout_fields = Package::container()->get( CheckoutFields::class );
387+
if ( ! $checkout_fields instanceof CheckoutFields ) {
388+
return [];
389+
}
390+
391+
$custom_checkout_fields = [];
392+
$additional_fields = $checkout_fields->get_additional_fields();
393+
foreach ( $additional_fields as $field_key => $field ) {
394+
$location = $checkout_fields->get_field_location( $field_key );
395+
$custom_checkout_fields[ $field_key ] = [
396+
'type' => $field['type'],
397+
'location' => $location,
398+
];
399+
}
400+
401+
return $custom_checkout_fields;
402+
} catch ( Exception $e ) {
241403
return [];
242404
}
405+
}
243406

244-
$custom_checkout_fields = [];
245-
$additional_fields = $checkout_fields->get_additional_fields();
246-
foreach ( $additional_fields as $field_key => $field ) {
247-
$location = $checkout_fields->get_field_location( $field_key );
248-
$custom_checkout_fields[ $field_key ] = [
249-
'key' => $field_key,
250-
'location' => $location,
251-
];
407+
// Classic checkout page
408+
if ( is_checkout() ) {
409+
$custom_checkout_fields = [];
410+
$standard_checkout_fields = $this->get_standard_checkout_fields();
411+
$all_fields = WC()->checkout()->get_checkout_fields();
412+
foreach ( $all_fields as $fieldset => $fields ) {
413+
foreach ( $fields as $field_key => $field ) {
414+
if ( in_array( $field_key, $standard_checkout_fields, true ) ) {
415+
continue;
416+
}
417+
418+
$custom_checkout_fields[ $field_key ] = [
419+
'type' => $field['type'],
420+
'location' => $fieldset,
421+
];
422+
}
252423
}
253424

254425
return $custom_checkout_fields;
255-
} catch ( Exception $e ) {
256-
return [];
257426
}
427+
428+
// Not a checkout page, e.g. product page, cart page.
429+
return [];
430+
}
431+
432+
/**
433+
* Get standard checkout fields.
434+
*
435+
* @return array Standard checkout fields.
436+
*/
437+
private function get_standard_checkout_fields() {
438+
$default_address_fields = WC()->countries->get_default_address_fields();
439+
$standard_billing_fields = array_map(
440+
function ( $field ) {
441+
return 'billing_' . $field;
442+
},
443+
array_keys( $default_address_fields )
444+
);
445+
446+
$standard_shipping_fields = array_map(
447+
function ( $field ) {
448+
return 'shipping_' . $field;
449+
},
450+
array_keys( $default_address_fields )
451+
);
452+
453+
$standard_checkout_fields = array_merge(
454+
$standard_billing_fields,
455+
$standard_shipping_fields,
456+
[ 'billing_phone', 'billing_email', 'order_comments' ]
457+
);
458+
459+
return $standard_checkout_fields;
258460
}
259461

260462
/**

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
154154
* Dev - Add Klarna e2e tests
155155
* Tweak - Update deprecation notice message to specify that legacy checkout experience has been deprecated since version 9.6.0
156156
* Update - Remove legacy checkout checkbox from settings
157+
* Update - Express Checkout: introduce new WP actions for supporting custom checkout fields for classic, shortcode-based checkout
157158
* Fix - Fixes page crash when Klarna payment method is not supported in the merchant's country by returning an empty array instead of throwing an error
158159
* Fix - Fix payment processing for $0 subscription with recurring coupon
159160
* Dev - Add e2e tests to cover Affirm purchase flow

0 commit comments

Comments
 (0)