Skip to content

Commit 6fbaf62

Browse files
Merge pull request #4319 from woocommerce/dev/PCP-6273-add-validation-context
Add context data to validation issues (6273)
2 parents 02b67e6 + 7cbd70f commit 6fbaf62

13 files changed

Lines changed: 577 additions & 126 deletions

modules/ppcp-store-sync/src/CartValidation/InventoryValidator.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use WooCommerce\PayPalCommerce\StoreSync\Helper\ProductManager;
1515
use WooCommerce\PayPalCommerce\StoreSync\Schema\PayPalCart;
1616
use WooCommerce\PayPalCommerce\StoreSync\Schema\CartItem;
17+
use WooCommerce\PayPalCommerce\StoreSync\Validation\Context\InventoryIssueContext;
1718
use WooCommerce\PayPalCommerce\StoreSync\Validation\Resolution\ResolutionOption;
1819
use WooCommerce\PayPalCommerce\StoreSync\Validation\ValidationIssue;
1920

@@ -56,6 +57,10 @@ private function validate_product( int $key, CartItem $item ): ?ValidationIssue
5657
return ValidationIssue::create_item_out_of_stock( 'Product is no longer available' )
5758
->user_message( sprintf( '%s is currently out of stock.', $product->get_name() ) )
5859
->for_field( $field )
60+
->add_context(
61+
InventoryIssueContext::create_item_out_of_stock()
62+
->item_id( $item->item_id() )
63+
)
5964
->add_resolution(
6065
ResolutionOption::create_remove_item()
6166
->label( 'Remove from cart' )
@@ -77,6 +82,12 @@ private function validate_product( int $key, CartItem $item ): ?ValidationIssue
7782
)
7883
)
7984
->for_field( $field )
85+
->add_context(
86+
InventoryIssueContext::create_insufficient_inventory()
87+
->item_id( $item->item_id() )
88+
->available_quantity( $stock_quantity )
89+
->requested_quantity( $item->quantity() )
90+
)
8091
->add_resolution(
8192
ResolutionOption::create_modify_cart()
8293
->label( sprintf( 'Reduce quantity to %d', $stock_quantity ) )

modules/ppcp-store-sync/src/CartValidation/ProductValidator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use WooCommerce\PayPalCommerce\StoreSync\Schema\PayPalCart;
1616
use WooCommerce\PayPalCommerce\StoreSync\Schema\CartItem;
1717
use WooCommerce\PayPalCommerce\StoreSync\Validation\Resolution\ResolutionOption;
18+
use WooCommerce\PayPalCommerce\StoreSync\Validation\Context\DataErrorContext;
1819
use WooCommerce\PayPalCommerce\StoreSync\Validation\ValidationIssue;
1920
use WooCommerce\PayPalCommerce\StoreSync\Config\IngestionConfiguration;
2021

@@ -58,6 +59,7 @@ private function validate_product( int $key, CartItem $item ): ?ValidationIssue
5859
return ValidationIssue::create_invalid_product( "Product '{$identifier}' not found in WooCommerce catalog" )
5960
->user_message( "'{$item->name()}' not found in WooCommerce catalog" )
6061
->for_field( $field )
62+
->add_context( DataErrorContext::create_item_not_found() )
6163
->add_resolution(
6264
ResolutionOption::create_remove_item()
6365
->label( 'Remove from cart' )

modules/ppcp-store-sync/src/CartValidation/ShippingValidator.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ private function validate_address_completeness( Address $address ): array {
120120
ValidationIssue::create_invalid_address( 'Shipping address is missing street address' )
121121
->user_message( 'Please provide a complete street address.' )
122122
->for_field( 'shipping_address.address_line_1' )
123+
->add_context( ShippingErrorContext::create_shipping_address_unserviceable() )
123124
->add_resolution(
124125
ResolutionOption::create_provide_missing_field()
125126
->label( 'Provide street address' )
@@ -137,6 +138,7 @@ private function validate_address_completeness( Address $address ): array {
137138
ValidationIssue::create_invalid_address( 'Shipping address is missing city' )
138139
->user_message( 'Please provide a city.' )
139140
->for_field( 'shipping_address.admin_area_2' )
141+
->add_context( ShippingErrorContext::create_shipping_address_unserviceable() )
140142
->add_resolution(
141143
ResolutionOption::create_provide_missing_field()
142144
->label( 'Provide city' )
@@ -155,6 +157,7 @@ private function validate_address_completeness( Address $address ): array {
155157
ValidationIssue::create_invalid_address( 'Shipping address is missing postal code' )
156158
->user_message( 'Please provide a postal code.' )
157159
->for_field( 'shipping_address.postal_code' )
160+
->add_context( ShippingErrorContext::create_shipping_address_unserviceable() )
158161
->add_resolution(
159162
ResolutionOption::create_provide_missing_field()
160163
->label( 'Provide postal code' )
@@ -201,6 +204,7 @@ private function validate_postal_code_format( string $postal_code, ?string $coun
201204
)
202205
->user_message( 'Please provide a valid postal code.' )
203206
->for_field( 'shipping_address.postal_code' )
207+
->add_context( ShippingErrorContext::create_shipping_address_unserviceable() )
204208
->add_resolution(
205209
ResolutionOption::create_update_address()
206210
->label( 'Correct the postal code' )
@@ -339,6 +343,10 @@ private function validate_country( ?string $country_code ): ?ValidationIssue {
339343
return ValidationIssue::create_shipping_unavailable( sprintf( 'Shipping to %s is not available', $country_code ) )
340344
->user_message( sprintf( 'We do not ship to %s.', $this->get_country_name( $country_code ) ) )
341345
->for_field( 'shipping_address.country_code' )
346+
->add_context(
347+
ShippingErrorContext::create_shipping_not_available()
348+
->destination_country( $country_code )
349+
)
342350
->add_resolution(
343351
ResolutionOption::create_update_address()
344352
->label( 'Use a different shipping country' )

modules/ppcp-store-sync/src/Endpoint/CheckoutEndpoint.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
use WooCommerce\PayPalCommerce\StoreSync\Errors\AgenticError;
2121
use WooCommerce\PayPalCommerce\StoreSync\Errors\Http\InternalServerError;
22+
use WooCommerce\PayPalCommerce\StoreSync\Validation\Context\PaymentErrorContext;
23+
use WooCommerce\PayPalCommerce\StoreSync\Validation\ValidationIssue;
2224
use WooCommerce\PayPalCommerce\StoreSync\Schema\PaymentMethod;
2325
use WooCommerce\PayPalCommerce\StoreSync\Helper\AgenticSessionManager;
2426
use WooCommerce\PayPalCommerce\StoreSync\Helper\AgenticCheckoutProcessor;
@@ -130,7 +132,16 @@ public function complete_checkout( WP_REST_Request $request ): WP_REST_Response
130132
$order = $this->create_wc_order( $cart, $payment_method, $session['ec_token'] );
131133

132134
if ( is_wp_error( $order ) ) {
133-
return $this->error( InternalServerError::from_wp_error( $order ) );
135+
$issue = ValidationIssue::create_payment_error( $order->get_error_message() )
136+
->add_context(
137+
PaymentErrorContext::create_payment_declined()
138+
->decline_reason( (string) $order->get_error_code() )
139+
);
140+
$cart_response = $this->response_factory->from_cart(
141+
$cart->with_validation_issues( $issue ),
142+
$cart_id
143+
);
144+
return $this->cart_details( $cart_response, 200 );
134145
}
135146

136147
$this->flush_local_cart( $cart_id );

modules/ppcp-store-sync/src/Enums/ContextShippingIssue.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ class ContextShippingIssue {
2020
public const HAZARDOUS_MATERIAL_SHIPPING = 'HAZARDOUS_MATERIAL_SHIPPING';
2121
public const SHIPPING_ZONE_NOT_COVERED = 'SHIPPING_ZONE_NOT_COVERED';
2222
public const MISSING_COORDINATES_FOR_ENHANCED_DELIVERY = 'MISSING_COORDINATES_FOR_ENHANCED_DELIVERY';
23+
public const SHIPPING_NOT_AVAILABLE = 'SHIPPING_NOT_AVAILABLE';
24+
public const SHIPPING_ADDRESS_UNSERVICEABLE = 'SHIPPING_ADDRESS_UNSERVICEABLE';
2325
}

modules/ppcp-store-sync/src/Validation/Context/ShippingErrorContext.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ public static function create_missing_coordinates_for_enhanced_delivery(): self
5353
return new self( ContextShippingIssue::MISSING_COORDINATES_FOR_ENHANCED_DELIVERY );
5454
}
5555

56+
public static function create_shipping_not_available(): self {
57+
return new self( ContextShippingIssue::SHIPPING_NOT_AVAILABLE );
58+
}
59+
60+
public static function create_shipping_address_unserviceable(): self {
61+
return new self( ContextShippingIssue::SHIPPING_ADDRESS_UNSERVICEABLE );
62+
}
63+
5664
private const VALID_RESTRICTION_REASONS = array(
5765
'signature_required',
5866
'age_verification_required',

modules/ppcp-store-sync/src/Validation/ValidationIssue.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ public static function create_missing_field( string $message ): self {
161161
return new self( $message, ErrorCode::DATA_ERROR, ErrorType::MISSING_FIELD );
162162
}
163163

164+
/**
165+
* When to use:
166+
* - Payment was declined by the processor.
167+
*/
168+
public static function create_payment_error( string $message ): self {
169+
return new self( $message, ErrorCode::PAYMENT_ERROR, ErrorType::BUSINESS_RULE );
170+
}
171+
164172
/**
165173
* Returns the error code, which is a high-level description of the problem.
166174
* Possible values are defined in the `Enums/ErrorCode` class.
Lines changed: 32 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
<?php
2-
declare(strict_types=1);
2+
declare( strict_types = 1 );
33

44
namespace WooCommerce\PayPalCommerce\StoreSync\CartValidation;
55

66
use Mockery;
77
use WooCommerce\PayPalCommerce\StoreSync\Helper\ProductManager;
8-
use WooCommerce\PayPalCommerce\StoreSync\Schema\PayPalCart;
98
use WooCommerce\PayPalCommerce\StoreSync\Validation\ValidationIssue;
109
use WooCommerce\PayPalCommerce\StoreSync\CartValidation\CouponValidator\CouponValidator;
1110
use WooCommerce\PayPalCommerce\StoreSync\CartValidation\CouponValidator\CouponContextBuilder;
@@ -15,97 +14,91 @@
1514
/**
1615
* @covers \WooCommerce\PayPalCommerce\StoreSync\CartValidation\CouponValidator\CouponValidator
1716
*/
18-
class CouponValidatorTest extends ValidationTest
19-
{
17+
class CouponValidatorTest extends ValidationTest {
2018

2119
private CouponValidator $validator;
2220
private $product_manager;
2321
private $context_builder;
2422
private $discount_calculator;
2523
private $resolution_builder;
2624

27-
public function setUp(): void
28-
{
25+
public function setUp(): void {
2926
parent::setUp();
30-
\Brain\Monkey\Functions\when('get_woocommerce_currency')->justReturn('USD');
27+
\Brain\Monkey\Functions\when( 'get_woocommerce_currency' )->justReturn( 'USD' );
3128
// Note: wc_coupons_enabled() is stubbed per-test as needed
32-
$this->product_manager = Mockery::mock(ProductManager::class);
33-
$this->discount_calculator = new DiscountCalculator($this->product_manager);
34-
$this->context_builder = new CouponContextBuilder($this->product_manager, $this->discount_calculator);
35-
$this->resolution_builder = new CouponResolutionBuilder();
36-
$this->validator = new CouponValidator(
29+
$this->product_manager = Mockery::mock( ProductManager::class );
30+
$this->discount_calculator = new DiscountCalculator( $this->product_manager );
31+
$this->context_builder =
32+
new CouponContextBuilder( $this->product_manager, $this->discount_calculator );
33+
$this->resolution_builder = new CouponResolutionBuilder();
34+
$this->validator = new CouponValidator(
3735
$this->context_builder,
3836
$this->discount_calculator,
3937
$this->resolution_builder
4038
);
4139
}
4240

43-
public function test_validate_returns_null_for_cart_without_coupons(): void
44-
{
45-
$cart = $this->create_cart_with_coupons(array());
41+
public function test_validate_returns_null_for_cart_without_coupons(): void {
42+
$cart = $this->create_cart_with_coupons( array() );
4643

47-
$result = $this->validator->validate($cart);
44+
$result = $this->validator->validate( $cart );
4845

49-
$this->assertNull($result);
46+
$this->assertNull( $result );
5047
}
5148

52-
public function test_validate_skips_remove_action_coupons(): void
53-
{
49+
public function test_validate_skips_remove_action_coupons(): void {
5450
$cart = $this->create_cart_with_coupons(
5551
array(
56-
array('code' => 'REMOVE_ME', 'action' => 'REMOVE'),
52+
array( 'code' => 'REMOVE_ME', 'action' => 'REMOVE' ),
5753
)
5854
);
5955

60-
$result = $this->validator->validate($cart);
56+
$result = $this->validator->validate( $cart );
6157

6258
// REMOVE actions are skipped entirely - no WC_Coupon instantiation needed.
63-
$this->assertNull($result);
59+
$this->assertNull( $result );
6460
}
6561

66-
public function test_validate_returns_error_when_coupons_disabled(): void
67-
{
62+
public function test_validate_returns_error_when_coupons_disabled(): void {
6863
// This test requires actual WooCommerce classes, so skip in unit test environment.
6964
// TODO: This test does not run in a unit-test environment. How is it executed?
7065
if ( ! class_exists( 'WC_Coupon' ) || ! class_exists( 'WC_Discounts' ) ) {
7166
$this->markTestSkipped( 'WooCommerce classes not available in unit test environment' );
7267
}
7368

7469
// Stub wc_coupons_enabled to return false for this test.
75-
\Brain\Monkey\Functions\when('wc_coupons_enabled')->justReturn(false);
70+
\Brain\Monkey\Functions\when( 'wc_coupons_enabled' )->justReturn( false );
7671

7772
$cart = $this->create_cart_with_coupons(
7873
array(
79-
array('code' => 'TESTCODE', 'action' => 'APPLY'),
74+
array( 'code' => 'TESTCODE', 'action' => 'APPLY' ),
8075
)
8176
);
8277

83-
$result = $this->validator->validate($cart);
78+
$result = $this->validator->validate( $cart );
8479

85-
$this->assertIsArray($result);
86-
$this->assertCount(1, $result);
80+
$this->assertIsArray( $result );
81+
$this->assertCount( 1, $result );
8782

8883
$data = $result[0]->to_array();
89-
$this->assertValidationIssue($data, 'PRICING_ERROR', 'BUSINESS_RULE', 'coupons', 'Coupons are not enabled');
84+
$this->assertValidationIssue( $data, 'PRICING_ERROR', 'BUSINESS_RULE', 'coupons', 'Coupons are not enabled' );
9085
}
9186

92-
public function test_validate_filters_only_apply_actions(): void
93-
{
87+
public function test_validate_filters_only_apply_actions(): void {
9488
$cart = $this->create_cart_with_coupons(
9589
array(
96-
array('code' => 'KEEP_THIS', 'action' => 'REMOVE'),
97-
array('code' => 'ALSO_REMOVE', 'action' => 'REMOVE'),
90+
array( 'code' => 'KEEP_THIS', 'action' => 'REMOVE' ),
91+
array( 'code' => 'ALSO_REMOVE', 'action' => 'REMOVE' ),
9892
)
9993
);
10094

101-
$result = $this->validator->validate($cart);
95+
$result = $this->validator->validate( $cart );
10296

10397
// All coupons are REMOVE actions, so nothing to validate.
104-
$this->assertNull($result);
98+
$this->assertNull( $result );
10599
}
106100

107-
public function test_coupon_invalid_issue_has_correct_error_code(): void
108-
{
101+
public function test_coupon_invalid_issue_has_correct_error_code(): void {
109102
$issue = ValidationIssue::create_coupon_invalid( 'Test message' )
110103
->user_message( 'Test user message' )
111104
->for_field( 'coupons[0]' );
@@ -114,8 +107,7 @@ public function test_coupon_invalid_issue_has_correct_error_code(): void
114107
$this->assertValidationIssue( $data, 'PRICING_ERROR', 'BUSINESS_RULE' );
115108
}
116109

117-
public function test_coupon_invalid_truncates_long_messages(): void
118-
{
110+
public function test_coupon_invalid_truncates_long_messages(): void {
119111
$long_message = str_repeat( 'a', 300 );
120112
$long_user_message = str_repeat( 'b', 600 );
121113

@@ -129,38 +121,4 @@ public function test_coupon_invalid_truncates_long_messages(): void
129121
$this->assertSame( 500, strlen( $data['user_message'] ) );
130122
}
131123

132-
/**
133-
* Helper to create a cart with coupons.
134-
*/
135-
private function create_cart_with_coupons(array $coupons, float $subtotal = 50.00, string $customer_email = ''): PayPalCart
136-
{
137-
$cart_data = array(
138-
'items' => array(
139-
array(
140-
'item_id' => '1',
141-
'quantity' => 1,
142-
'name' => 'Test Product',
143-
'price' => array(
144-
'currency_code' => 'USD',
145-
'value' => $subtotal,
146-
),
147-
),
148-
),
149-
'payment_method' => array(
150-
'type' => 'paypal',
151-
),
152-
);
153-
154-
if (!empty($coupons)) {
155-
$cart_data['coupons'] = $coupons;
156-
}
157-
158-
if ($customer_email) {
159-
$cart_data['customer'] = array(
160-
'email_address' => $customer_email,
161-
);
162-
}
163-
164-
return PayPalCart::from_array($cart_data);
165-
}
166124
}

tests/PHPUnit/StoreSync/CartValidation/CurrencyValidatorTest.php

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -215,32 +215,4 @@ public function test_mixed_currency_prevents_store_check(): void {
215215
$this->assertValidationIssue( $issue, 'PRICING_ERROR', 'BUSINESS_RULE', null, 'Mixed currencies detected' );
216216
}
217217

218-
/**
219-
* Helper method to create a cart with items.
220-
*
221-
* @param array $items Array of items with currency and value.
222-
* @return PayPalCart
223-
*/
224-
private function create_cart_with_items( array $items ): PayPalCart {
225-
$cart_items = array();
226-
227-
foreach ( $items as $index => $item_data ) {
228-
$cart_items[] = array(
229-
'item_id' => (string) ( $index + 1 ),
230-
'quantity' => 1,
231-
'name' => "Item $index",
232-
'price' => array(
233-
'currency_code' => $item_data['currency'],
234-
'value' => $item_data['value'],
235-
),
236-
);
237-
}
238-
239-
return PayPalCart::from_array(
240-
array(
241-
'items' => $cart_items,
242-
'payment_method' => 'paypal',
243-
)
244-
);
245-
}
246218
}

0 commit comments

Comments
 (0)