Skip to content

Commit 62d002b

Browse files
authored
Merge pull request #4476 from woocommerce/trunk
Update dev/develop with the changes in trunk
2 parents 5e04228 + 8d79811 commit 62d002b

76 files changed

Lines changed: 5320 additions & 368 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
1+
# Do not track private authentication details
2+
auth.json
3+
.npmrc
4+
.env*
5+
6+
# Exclude runtime artifacts by file types
7+
*.cache
8+
*.bak
9+
*.log
10+
*.zip
11+
.DS_Store
12+
13+
# Ignore IDE settings
14+
/.idea/
15+
.vscode
16+
117
/vendor/
218
/build/
3-
/.idea/
419
node_modules
5-
.phpunit.result.cache
6-
phpunit.xml.dist.bak
7-
yarn-error.log
820
modules/*/vendor/*
921
modules/*/assets/*
1022
!modules/ppcp-wc-gateway/assets/images
11-
*.zip
1223
!tests/qa/resources/files/*.zip
1324
!tests/qa-legacy-ui/resources/files/*.zip
14-
.env*
1525
!.env.example*
16-
auth.json
17-
.DS_Store
18-
tests/.DS_Store
1926
.composer_compiled_assets
2027
/coverage/
2128
/assets
2229
!modules/ppcp-local-alternative-payment-methods/assets/images
2330
tests/inc/inpsyde/
2431

25-
.vscode
2632
playwright-utils
2733
playwright-report*
2834
snapshots

changelog.txt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
*** Changelog ***
22

3+
= 4.1.0 - 2026-06-23 =
4+
* Enhancement - New Vault Management Component for buyers with saved PayPal accounts to update funding instrument #4421
5+
* Enhancement - Pay upon Invoice support for Block Checkout #4405
6+
* Enhancement - OXXO support for Block Checkout #4381
7+
* Enhancement - Show PayPal account or last card digits in payment method on order confirmation page & emails #4427
8+
* Enhancement - Disable legacy "PayPal Standard" gateway when merchant account is connected to PayPal Payments #4463
9+
* Fix - Third-party plugin gift cards not calculated on PayPal payment modal #4422
10+
* Fix - Fix PayPal amount mismatch from inclusive-tax rounding #4425
11+
* Fix - Negative fee calculation may result in a mismatch PayPal between the WooCommerce order total and the total amount paid via PayPal #4409
12+
* Fix - Unsuccessful payment for vaulting subscription with ACDC from Block Checkout #4437
13+
* Fix - Unsuccessful payment for vaulting subscriptions with 100% discount coupons (CANNOT_BE_ZERO_OR_NEGATIVE) #4452
14+
* Fix - Unsuccessful payment for free trial vaulting subscription with acdc and PayPal #4439
15+
* Fix - Vaulted ACDC saved-card checkout stuck in "payment pending" #4431
16+
* Fix - PartnersEndpoint::seller_status() floods PayPal merchant-integrations endpoint with 403s #4430
17+
* Fix - Avoid spamming OAuth requests when auth fails #4414
18+
* Fix - Google Pay not rendered on variable product page #4433
19+
* Fix - Simplify manual client ID validation to accommodate new Client ID format #4397
20+
* Fix - Mismatch line item not delivered to PayPal overview transaction page #4426
21+
* Fix - APM transaction links open paypal.com instead of sandbox.paypal.com in Sandbox mode #4418
22+
* Fix - Coerce stored values in typed-bool getters and vault_enabled migration #4393
23+
* Fix - Fix type casting for customer unique ID in session MD5 hash #4333
24+
* Fix - Apple Pay fails on single product page when no shipping option is available #4375
25+
* Fix - Settings notifications do not display on the PayPal Payments settings pages #4034
26+
* Fix - Add missing "Show logos of supported cards" setting in the ACDC options #4380
27+
* Fix - Phone number verification may fail #4448
28+
* Fix - Saved payment methods display in continuation mode #4457
29+
* Fix - Card payments with saved cards may fail in certain cases #4450
30+
331
= 4.0.4 - 2026-05-19 =
432
* Enhancement - WooCommerce Blueprints integration to import & export the PayPal Payments configuration #4363
533
* Enhancement - PayPal redirect gateway now appears on Block checkout for Subscription-type products #4338

docs/testing-card-vaulting.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Card vaulting & vaulted checkout: testing notes
2+
3+
Vaulting tests need PayPal's "**save payment methods**" test cards, which are **not
4+
interchangeable** with the "purchase flow" cards. Using the wrong table is the most common mistake
5+
here.
6+
7+
## Sandbox setup
8+
9+
To exercise the 3DS path at all, set the plugin's **3D Secure** option to **ALWAYS**. The sandbox
10+
does not require 3DS by default, so with any other setting the test cards below authenticate without
11+
triggering the flow you want to test.
12+
13+
## 3DS modes
14+
15+
3D Secure authentication has two possible behaviors. Both must work identically; the only difference
16+
is whether the shopper has to enter an OTP.
17+
18+
| | Frictionless ("no challenge") | Step-up ("challenge") |
19+
|------------------|-------------------------------------------|--------------------------------------|
20+
| **What it is** | No user input; the bank approves silently | A manual OTP step is required |
21+
| **New card** | Overlay closes itself after a few seconds | Overlay expands into an OTP form |
22+
| **Vaulted card** | Redirects out, then back to the shop | Redirects out, returns after the OTP |
23+
24+
- "New card" = entering card details at checkout or adding one in My Account.
25+
- "Vaulted card" = paying with a saved card at checkout.
26+
- "Checkout" = Block checkout or Classic checkout, both are identical in this aspect.
27+
28+
## Cards to use for vaulting tests
29+
30+
Current "Save payment methods" table (expiry = `01/current year + 3`).
31+
32+
| Network | Frictionless | Step-up |
33+
|------------|--------------------|--------------------|
34+
| Visa | `4000000000002701` | `4000000000002503` |
35+
| Mastercard | `5200000000002235` | `5200000000002151` |
36+
37+
Do **not** use purchase-flow cards (e.g. `4868719196829038`, `4868719166101368`,
38+
`5329879707824603`) for vaulting. They mint a token that looks valid (every request returns `2xx`),
39+
but the later charge fails with a very visible `403 PERMISSION_DENIED`.
40+
41+
## References
42+
43+
- [Cards for "Save payment methods"](https://developer.paypal.com/docs/checkout/advanced/customize/3d-secure/test/#test-cases-and-card-details-for-save-payment-methods)
44+
*what we need for vaulting!*
45+
- [Cards for "Purchase flow"](https://developer.paypal.com/docs/checkout/advanced/customize/3d-secure/test/#test-cases-and-card-details-for-purchase-flows) -
46+
*not usable for vaulting, added for distinction*
47+
48+
## Test coverage
49+
50+
The 3DS authentication itself runs in the browser SDK and on PayPal's servers, so the end-to-end
51+
save + 3DS + charge flow can only be verified **manually** (real browser, with the sandbox setup and
52+
save-flow cards above). A live-API integration test would be non-deterministic.
53+
54+
The surrounding plugin logic is covered by automated tests that mock the PayPal HTTP boundary and
55+
assert what our code sends and does with responses:
56+
57+
- [Integration: VaultedCard3dsTransactionTest.php](../tests/integration/PHPUnit/Transaction/VaultedCard3dsTransactionTest.php):
58+
resume-nonce / capture / authorize state machine (uses a `payer-action` link to represent
59+
"3DS required").
60+
- [Integration: WooCommercePaymentTokensTest.php](../tests/integration/PHPUnit/Vaulting/WooCommercePaymentTokensTest.php):
61+
response → `WC_Payment_Token_CC` persistence.
62+
- [Unit: CaptureCardPaymentTest.php](../tests/PHPUnit/WcGateway/Endpoint/CaptureCardPaymentTest.php):
63+
vaulted-charge order-body construction.
64+
- [Unit: CreateSetupTokenTest.php](../tests/PHPUnit/SavePaymentMethods/Endpoint/CreateSetupTokenTest.php)
65+
and [Unit: CreatePaymentTokenTest.php](../tests/PHPUnit/SavePaymentMethods/Endpoint/CreatePaymentTokenTest.php):
66+
vault-token setup-request body and exchange-response handling.

modules/ppcp-api-client/src/Authentication/PayPalBearer.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,17 @@ private function newBearer(): Token {
189189
$status_code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
190190
$this->rate_limiter->register_failure( self::RATE_LIMIT_SCOPE, $status_code, $response );
191191

192-
$error = new RuntimeException( 'Could not create token.' );
192+
$error = new RuntimeException( 'Could not create token.' );
193+
$log_args = $args;
194+
foreach ( array_keys( $log_args['headers'] ) as $header_name ) {
195+
if ( preg_match( '/authorization|signature/i', $header_name ) ) {
196+
$log_args['headers'][ $header_name ] = '[REDACTED]';
197+
}
198+
}
193199
$this->logger->warning(
194200
$error->getMessage(),
195201
array(
196-
'args' => $args,
202+
'args' => $log_args,
197203
'response' => $response,
198204
)
199205
);

modules/ppcp-api-client/src/Endpoint/LoginSeller.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ private function generate_token_for(
141141
string $seller_nonce
142142
): string {
143143

144-
$url = trailingslashit( $this->host ) . 'v1/oauth2/token/';
145-
$args = array(
144+
$url = trailingslashit( $this->host ) . 'v1/oauth2/token/';
145+
$args = array(
146146
'method' => 'POST',
147147
'headers' => array(
148148
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode

modules/ppcp-api-client/src/Factory/PayerFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ public function from_paypal_response( \stdClass $data ): Payer {
131131
isset( $data->name->surname ) ? (string) $data->name->surname : ''
132132
);
133133
// TODO deal with phones without type instead of passing a invalid type.
134-
$phone = ( isset( $data->phone ) ) ? new PhoneWithType(
134+
$phone = ( isset( $data->phone->phone_number->national_number ) ) ? new PhoneWithType(
135135
( isset( $data->phone->phone_type ) ) ? $data->phone->phone_type : 'undefined',
136136
new Phone(
137137
$data->phone->phone_number->national_number

modules/ppcp-api-client/src/Repository/PartnerReferralsData.php

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,6 @@ public function __construct( DccApplies $dcc_applies, FeaturesDefinition $featur
3030
$this->features_definition = $features_definition;
3131
}
3232

33-
/**
34-
* Returns a nonce.
35-
*
36-
* @return string
37-
*/
38-
public function nonce(): string {
39-
return 'a1233wtergfsdt4365tzrshgfbaewa36AGa1233wtergfsdt4365tzrshgfbaewa36AG';
40-
}
41-
4233
/**
4334
* Returns the data.
4435
*
@@ -47,13 +38,15 @@ public function nonce(): string {
4738
* @param string $onboarding_token A security token to finalize the onboarding process.
4839
* @param bool $use_subscriptions If the merchant requires subscription features.
4940
* @param bool $use_card_payments If the merchant wants to process credit card payments.
41+
* @param string $seller_nonce The PKCE code_verifier for the OAuth flow.
5042
* @return array
5143
*/
5244
public function data(
5345
array $products = array(),
5446
string $onboarding_token = '',
5547
?bool $use_subscriptions = null,
56-
bool $use_card_payments = true
48+
bool $use_card_payments = true,
49+
string $seller_nonce = ''
5750
): array {
5851
$in_acdc_country = $this->dcc_applies->for_country_currency(); // @phpstan-ignore property.deprecated
5952

@@ -129,7 +122,7 @@ public function data(
129122
'integration_type' => 'FIRST_PARTY',
130123
'first_party_details' => array(
131124
'features' => $first_party_features,
132-
'seller_nonce' => $this->nonce(),
125+
'seller_nonce' => $seller_nonce,
133126
),
134127
),
135128
),

modules/ppcp-axo/src/Gateway/AxoGateway.php

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ public function process_payment( $order_id ) {
296296
}
297297
} catch ( Exception $exception ) {
298298
$this->logger->error( '[AXO] Payment processing failed: ' . $exception->getMessage() );
299+
299300
return array(
300301
'result' => 'failure',
301302
'message' => $this->get_user_friendly_error_message( $exception ),
@@ -330,6 +331,47 @@ protected function process_3ds_return( WC_Order $wc_order, string $token ): arra
330331
);
331332
}
332333

334+
$purchase_units = $paypal_order->purchase_units();
335+
$purchase_unit = $purchase_units[0] ?? null;
336+
337+
if ( null === $purchase_unit ) {
338+
$this->logger->error(
339+
sprintf(
340+
'[AXO] 3DS return validation failed: PayPal order has no purchase units. WC order ID: %s',
341+
$wc_order->get_id(),
342+
)
343+
);
344+
345+
return array(
346+
'result' => 'failure',
347+
'message' => __( 'Payment validation failed. Please try again.', 'woocommerce-paypal-payments' ),
348+
);
349+
}
350+
351+
/*
352+
* If the custom_id === order_id condition is true, we know that the PayPal order
353+
* and our WC order match, and there's no need for other checks.
354+
*
355+
* Specifically, we do not compare the WC_Order total amount against the
356+
* purchase unit's value, as the PU value can deviate from the WC order total, which
357+
* might reject legitimate payments.
358+
*/
359+
if ( $purchase_unit->custom_id() !== (string) $wc_order->get_id() ) {
360+
$this->logger->error(
361+
sprintf(
362+
'[AXO] 3DS return validation failed: custom_id mismatch. Token: %s, WC order ID: %s, PayPal custom_id: %s',
363+
$token,
364+
$wc_order->get_id(),
365+
$purchase_unit->custom_id()
366+
)
367+
);
368+
369+
return array(
370+
'result' => 'failure',
371+
'message' => __( 'Payment validation failed. Please try again.', 'woocommerce-paypal-payments' ),
372+
);
373+
}
374+
333375
/**
334376
* This filter controls if the method 'process()' from OrderProcessor will be called.
335377
* So you can implement your own for example on subscriptions
@@ -344,6 +386,7 @@ protected function process_3ds_return( WC_Order $wc_order, string $token ): arra
344386
}
345387
} catch ( Exception $exception ) {
346388
$this->logger->error( '[AXO] 3DS return processing failed: ' . $exception->getMessage() );
389+
347390
return array(
348391
'result' => 'failure',
349392
'message' => $this->get_user_friendly_error_message( $exception ),
@@ -453,7 +496,7 @@ protected function build_payment_source_properties( string $payment_token ): obj
453496
'single_use_token' => $payment_token,
454497
);
455498

456-
$three_d_secure = $this->settings_model->get_three_d_secure_enum();
499+
$three_d_secure = $this->settings_model->get_three_d_secure_enum();
457500

458501
if ( 'SCA_ALWAYS' === $three_d_secure || 'SCA_WHEN_REQUIRED' === $three_d_secure ) {
459502
$properties['attributes'] = array(
@@ -482,7 +525,7 @@ protected function build_order_data(): array {
482525

483526
$data['experience_context'] = $experience_context->to_array();
484527

485-
$three_d_secure = $this->settings_model->get_three_d_secure_enum();
528+
$three_d_secure = $this->settings_model->get_three_d_secure_enum();
486529

487530
if ( $three_d_secure === 'SCA_ALWAYS' || $three_d_secure === 'SCA_WHEN_REQUIRED' ) {
488531
$data['transaction_context'] = array(

modules/ppcp-blocks/resources/js/Components/paypal-saved-token.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ export const PayPalSavedToken = ( {
3838
const vaultData = config?.scriptData?.vault_component;
3939
const isVaultEligible = vaultData?.is_eligible === true;
4040
const isSavedTokenSelected = token && token !== '0' && token !== 0;
41+
// A zero-total subscription cart (free trial or 100% coupon) must use the
42+
// save-without-purchase flow. The Vault Component is order-based and would
43+
// create a $0 order, which PayPal rejects with CANNOT_BE_ZERO_OR_NEGATIVE.
44+
const isFreeTrial = config?.scriptData?.is_free_trial_cart === true;
4145
const shouldShowVaultComponent =
42-
isVaultEligible && isSavedTokenSelected && ! vaultRenderFailed;
46+
isVaultEligible &&
47+
isSavedTokenSelected &&
48+
! isFreeTrial &&
49+
! vaultRenderFailed;
4350

4451
const handleVaultRenderError = useCallback( () => {
4552
setVaultRenderFailed( true );

modules/ppcp-blocks/resources/js/checkout-block.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ if ( blockEnabled ) {
7979
placeOrderButtonLabel: config.placeOrderButtonText,
8080
ariaLabel: config.title,
8181
canMakePayment: ( cartData ) => {
82+
// Free-trial subscriptions have a $0 total today but still
83+
// require a payment method to be vaulted for future renewals.
84+
if ( config.scriptData.is_free_trial_cart ) {
85+
return true;
86+
}
8287
const total = cartData?.cartTotals?.total_price;
8388
return parseInt( total ) > 0;
8489
},

0 commit comments

Comments
 (0)