Merge pull request #1832 from woocommerce/PCP-2158-apple-pay-use-checkout-form-data-to-update-shipping-and-billing

ApplePay use checkout form data to update shipping and billing (2158)
This commit is contained in:
Emili Castells 2023-11-15 12:02:57 +01:00 committed by GitHub
commit aec5e6431c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 421 additions and 212 deletions

View file

@ -16,7 +16,7 @@ class ApplepayButton {
this.buttonConfig = buttonConfig;
this.ppcpConfig = ppcpConfig;
this.paymentsClient = null;
this.form_saved = false;
this.formData = null;
this.contextHandler = ContextHandlerFactory.create(
this.context,
@ -24,10 +24,13 @@ class ApplepayButton {
this.ppcpConfig
);
this.updated_contact_info = []
this.updatedContactInfo = []
this.selectedShippingMethod = []
this.nonce = document.getElementById('woocommerce-process-checkout-nonce')?.value || buttonConfig.nonce
// Stores initialization data sent to the button.
this.initialPaymentRequest = null;
this.log = function() {
if ( this.buttonConfig.is_debug ) {
console.log('[ApplePayButton]', ...arguments);
@ -35,6 +38,13 @@ class ApplepayButton {
}
this.refreshContextData();
// Debug helpers
jQuery(document).on('ppcp-applepay-debug', () => {
console.log('ApplePayButton', this.context, this);
});
document.ppcpApplepayButtons = document.ppcpApplepayButtons || {};
document.ppcpApplepayButtons[this.context] = this;
}
init(config) {
@ -51,6 +61,7 @@ class ApplepayButton {
this.isInitialized = true;
this.applePayConfig = config;
const isEligible = this.applePayConfig.isEligible;
if (isEligible) {
this.fetchTransactionInfo().then(() => {
const isSubscriptionProduct = this.ppcpConfig?.data_client_id?.has_subscriptions === true;
@ -72,11 +83,6 @@ class ApplepayButton {
this.onButtonClick();
});
}
// Listen for changes on any input within the WooCommerce checkout form
jQuery('form.checkout').on('change', 'input, select, textarea', () => {
this.fetchTransactionInfo();
});
});
}
}
@ -93,6 +99,7 @@ class ApplepayButton {
async fetchTransactionInfo() {
this.transactionInfo = await this.contextHandler.transactionInfo();
}
/**
* Returns configurations relative to this button context.
*/
@ -117,6 +124,7 @@ class ApplepayButton {
return config;
}
initEventHandlers() {
const { wrapper, ppcpButtonWrapper } = this.contextConfig();
const wrapper_id = '#' + wrapper;
@ -136,22 +144,25 @@ class ApplepayButton {
syncButtonVisibility();
}
/**
* Starts an ApplePay session.
*/
applePaySession(paymentRequest) {
this.log('applePaySession', paymentRequest);
const session = new ApplePaySession(4, paymentRequest)
session.begin()
const session = new ApplePaySession(4, paymentRequest);
session.begin();
if (this.buttonConfig.product.needShipping) {
session.onshippingmethodselected = this.onshippingmethodselected(session)
session.onshippingcontactselected = this.onshippingcontactselected(session)
if (this.shouldRequireShippingInButton()) {
session.onshippingmethodselected = this.onShippingMethodSelected(session);
session.onshippingcontactselected = this.onShippingContactSelected(session);
}
session.onvalidatemerchant = this.onvalidatemerchant(session);
session.onpaymentauthorized = this.onpaymentauthorized(session);
session.onvalidatemerchant = this.onValidateMerchant(session);
session.onpaymentauthorized = this.onPaymentAuthorized(session);
return session;
}
/**
* Add a Apple Pay purchase button
* Adds an Apple Pay purchase button.
*/
addButton() {
this.log('addButton', this.context);
@ -188,8 +199,9 @@ class ApplepayButton {
async onButtonClick() {
this.log('onButtonClick', this.context);
const paymentDataRequest = this.paymentDataRequest();
// trigger woocommerce validation if we are in the checkout page
const paymentRequest = this.paymentRequest();
// Trigger woocommerce validation if we are in the checkout page.
if (this.context === 'checkout') {
const checkoutFormSelector = 'form.woocommerce-checkout';
const errorHandler = new ErrorHandler(
@ -198,13 +210,16 @@ class ApplepayButton {
);
try {
const formData = new FormData(document.querySelector(checkoutFormSelector));
this.form_saved = Object.fromEntries(formData.entries());
// This line should be reviewed, the widgetBuilder.paypal.Applepay().confirmOrder fails if we add it.
//this.update_request_data_with_form(paymentDataRequest);
this.formData = Object.fromEntries(formData.entries());
this.updateRequestDataWithForm(paymentRequest);
} catch (error) {
console.error(error);
}
const session = this.applePaySession(paymentDataRequest)
this.log('=== paymentRequest', paymentRequest);
const session = this.applePaySession(paymentRequest);
const formValidator = PayPalCommerceGateway.early_checkout_validation_enabled ?
new FormValidator(
PayPalCommerceGateway.ajax.validate_checkout.endpoint,
@ -225,19 +240,115 @@ class ApplepayButton {
}
return;
}
this.applePaySession(paymentDataRequest)
// Default session initialization.
this.applePaySession(paymentRequest);
}
update_request_data_with_form(paymentDataRequest) {
paymentDataRequest.billingContact = this.fill_billing_contact(this.form_saved);
paymentDataRequest.applicationData = this.fill_application_data(this.form_saved);
if (!this.buttonConfig.product.needShipping) {
/**
* If the button should show the shipping fields.
*
* @returns {false|*}
*/
shouldRequireShippingInButton() {
return this.contextHandler.shippingAllowed()
&& this.buttonConfig.product.needShipping
&& (this.context !== 'checkout' || this.shouldUpdateButtonWithFormData());
}
/**
* If the button should be updated with the form addresses.
*
* @returns {boolean}
*/
shouldUpdateButtonWithFormData() {
if (this.context !== 'checkout') {
return false;
}
return this.buttonConfig?.preferences?.checkout_data_mode === 'use_applepay';
}
/**
* Indicates how payment completion should be handled if with the context handler default actions.
* Or with ApplePay module specific completion.
*
* @returns {boolean}
*/
shouldCompletePaymentWithContextHandler() {
// Data already handled, ex: PayNow
if (!this.contextHandler.shippingAllowed()) {
return true;
}
// Use WC form data mode in Checkout.
if (this.context === 'checkout' && !this.shouldUpdateButtonWithFormData()) {
return true;
}
return false;
}
/**
* Updates ApplePay paymentRequest with form data.
*/
updateRequestDataWithForm(paymentRequest) {
if (!this.shouldUpdateButtonWithFormData()) {
return;
}
paymentDataRequest.shippingContact = this.fill_shipping_contact(this.form_saved);
// Add billing address.
paymentRequest.billingContact = this.fillBillingContact(this.formData);
// Add custom data.
// "applicationData" is originating a "PayPalApplePayError: An internal server error has occurred" on paypal.Applepay().confirmOrder().
// paymentRequest.applicationData = this.fillApplicationData(this.formData);
if (!this.shouldRequireShippingInButton()) {
return;
}
// Add shipping address.
paymentRequest.shippingContact = this.fillShippingContact(this.formData);
// Get shipping methods.
const rate = this.transactionInfo.chosenShippingMethods[0];
paymentRequest.shippingMethods = [];
// Add selected shipping method.
for (const shippingPackage of this.transactionInfo.shippingPackages) {
if (rate === shippingPackage.id) {
const shippingMethod = {
'label' : shippingPackage.label,
'detail' : '',
'amount' : shippingPackage.cost_str,
'identifier' : shippingPackage.id,
};
// Remember this shipping method as the selected one.
this.selectedShippingMethod = shippingMethod;
paymentRequest.shippingMethods.push(shippingMethod);
break;
}
}
// Add other shipping methods.
for (const shippingPackage of this.transactionInfo.shippingPackages) {
if (rate !== shippingPackage.id) {
paymentRequest.shippingMethods.push({
'label' : shippingPackage.label,
'detail' : '',
'amount' : shippingPackage.cost_str,
'identifier' : shippingPackage.id,
});
}
}
// Store for reuse in case this data is not provided by ApplePay on authorization.
this.initialPaymentRequest = paymentRequest;
this.log('=== paymentRequest.shippingMethods', paymentRequest.shippingMethods);
}
paymentDataRequest() {
paymentRequest() {
const applepayConfig = this.applePayConfig
const buttonConfig = this.buttonConfig
let baseRequest = {
@ -245,22 +356,28 @@ class ApplepayButton {
merchantCapabilities: applepayConfig.merchantCapabilities,
supportedNetworks: applepayConfig.supportedNetworks,
requiredShippingContactFields: ["postalAddress", "email", "phone"],
requiredBillingContactFields: ["postalAddress", "email", "phone"],
requiredBillingContactFields: ["postalAddress"], // ApplePay does not implement billing email and phone fields.
}
if (!this.contextHandler.shippingAllowed()) {
baseRequest.requiredShippingContactFields = [];
if (!this.shouldRequireShippingInButton()) {
if (this.shouldCompletePaymentWithContextHandler()) {
// Data needs handled externally.
baseRequest.requiredShippingContactFields = [];
} else {
// Minimum data required for order creation.
baseRequest.requiredShippingContactFields = ["email", "phone"];
}
}
const paymentDataRequest = Object.assign({}, baseRequest);
paymentDataRequest.currencyCode = buttonConfig.shop.currencyCode;
paymentDataRequest.total = {
const paymentRequest = Object.assign({}, baseRequest);
paymentRequest.currencyCode = buttonConfig.shop.currencyCode;
paymentRequest.total = {
label: buttonConfig.shop.totalLabel,
type: "final",
amount: this.transactionInfo.totalPrice,
}
return paymentDataRequest
return paymentRequest;
}
refreshContextData() {
@ -278,7 +395,7 @@ class ApplepayButton {
// Payment process
//------------------------
onvalidatemerchant(session) {
onValidateMerchant(session) {
this.log('onvalidatemerchant', this.buttonConfig.ajax_url);
return (applePayValidateMerchantEvent) => {
this.log('onvalidatemerchant call');
@ -312,83 +429,90 @@ class ApplepayButton {
validation: false,
'woocommerce-process-checkout-nonce': this.nonce,
}
})
});
this.log('onvalidatemerchant session abort');
session.abort();
});
};
}
onshippingmethodselected(session) {
onShippingMethodSelected(session) {
this.log('onshippingmethodselected', this.buttonConfig.ajax_url);
const ajax_url = this.buttonConfig.ajax_url
const ajax_url = this.buttonConfig.ajax_url;
return (event) => {
this.log('onshippingmethodselected call');
const data = this.getShippingMethodData(event);
jQuery.ajax({
url: ajax_url,
method: 'POST',
data: data,
success: (applePayShippingMethodUpdate, textStatus, jqXHR) => {
this.log('onshippingmethodselected ok');
let response = applePayShippingMethodUpdate.data
let response = applePayShippingMethodUpdate.data;
if (applePayShippingMethodUpdate.success === false) {
response.errors = createAppleErrors(response.errors)
response.errors = createAppleErrors(response.errors);
}
this.selectedShippingMethod = event.shippingMethod
//order the response shipping methods, so that the selected shipping method is the first one
let orderedShippingMethods = response.newShippingMethods.sort((a, b) => {
this.selectedShippingMethod = event.shippingMethod;
// Sort the response shipping methods, so that the selected shipping method is the first one.
response.newShippingMethods = response.newShippingMethods.sort((a, b) => {
if (a.label === this.selectedShippingMethod.label) {
return -1
return -1;
}
return 1
})
//update the response.newShippingMethods with the ordered shipping methods
response.newShippingMethods = orderedShippingMethods
return 1;
});
if (applePayShippingMethodUpdate.success === false) {
response.errors = createAppleErrors(response.errors)
response.errors = createAppleErrors(response.errors);
}
session.completeShippingMethodSelection(response)
session.completeShippingMethodSelection(response);
},
error: (jqXHR, textStatus, errorThrown) => {
this.log('onshippingmethodselected error', textStatus);
console.warn(textStatus, errorThrown)
session.abort()
console.warn(textStatus, errorThrown);
session.abort();
},
})
});
};
}
onshippingcontactselected(session) {
onShippingContactSelected(session) {
this.log('onshippingcontactselected', this.buttonConfig.ajax_url);
const ajax_url = this.buttonConfig.ajax_url
const ajax_url = this.buttonConfig.ajax_url;
return (event) => {
this.log('onshippingcontactselected call');
const data = this.getShippingContactData(event);
jQuery.ajax({
url: ajax_url,
method: 'POST',
data: data,
success: (applePayShippingContactUpdate, textStatus, jqXHR) => {
this.log('onshippingcontactselected ok');
let response = applePayShippingContactUpdate.data
this.updated_contact_info = event.shippingContact
let response = applePayShippingContactUpdate.data;
this.updatedContactInfo = event.shippingContact;
if (applePayShippingContactUpdate.success === false) {
response.errors = createAppleErrors(response.errors)
response.errors = createAppleErrors(response.errors);
}
if (response.newShippingMethods) {
this.selectedShippingMethod = response.newShippingMethods[0]
this.selectedShippingMethod = response.newShippingMethods[0];
}
session.completeShippingContactSelection(response)
session.completeShippingContactSelection(response);
},
error: (jqXHR, textStatus, errorThrown) => {
this.log('onshippingcontactselected error', textStatus);
console.warn(textStatus, errorThrown)
session.abort()
console.warn(textStatus, errorThrown);
session.abort();
},
})
});
};
}
getShippingContactData(event) {
const product_id = this.buttonConfig.product.id;
@ -403,7 +527,7 @@ class ApplepayButton {
caller_page: 'productDetail',
product_quantity: this.productQuantity,
simplified_contact: event.shippingContact,
need_shipping: this.buttonConfig.product.needShipping,
need_shipping: this.shouldRequireShippingInButton(),
'woocommerce-process-checkout-nonce': this.nonce,
};
case 'cart':
@ -415,11 +539,12 @@ class ApplepayButton {
action: 'ppcp_update_shipping_contact',
simplified_contact: event.shippingContact,
caller_page: 'cart',
need_shipping: this.buttonConfig.product.needShipping,
need_shipping: this.shouldRequireShippingInButton(),
'woocommerce-process-checkout-nonce': this.nonce,
};
}
}
getShippingMethodData(event) {
const product_id = this.buttonConfig.product.id;
@ -429,11 +554,11 @@ class ApplepayButton {
case 'product': return {
action: 'ppcp_update_shipping_method',
shipping_method: event.shippingMethod,
simplified_contact: this.updatedContactInfo || this.initialPaymentRequest.shippingContact || this.initialPaymentRequest.billingContact,
product_id: product_id,
products: JSON.stringify(this.products),
caller_page: 'productDetail',
product_quantity: this.productQuantity,
simplified_contact: this.updated_contact_info,
'woocommerce-process-checkout-nonce': this.nonce,
}
case 'cart':
@ -444,14 +569,14 @@ class ApplepayButton {
return {
action: 'ppcp_update_shipping_method',
shipping_method: event.shippingMethod,
simplified_contact: this.updatedContactInfo || this.initialPaymentRequest.shippingContact || this.initialPaymentRequest.billingContact,
caller_page: 'cart',
simplified_contact: this.updated_contact_info,
'woocommerce-process-checkout-nonce': this.nonce,
}
}
}
onpaymentauthorized(session) {
onPaymentAuthorized(session) {
this.log('onpaymentauthorized');
return async (event) => {
this.log('onpaymentauthorized call');
@ -462,8 +587,10 @@ class ApplepayButton {
const processInWooAndCapture = async (data) => {
return new Promise((resolve, reject) => {
try {
const billingContact = data.billing_contact
const shippingContact = data.shipping_contact
const billingContact = data.billing_contact || this.initialPaymentRequest.billingContact;
const shippingContact = data.shipping_contact || this.initialPaymentRequest.shippingContact;
const shippingMethod = this.selectedShippingMethod || (this.initialPaymentRequest.shippingMethods || [])[0];
let request_data = {
action: 'ppcp_create_order',
'caller_page': this.context,
@ -473,7 +600,7 @@ class ApplepayButton {
'shipping_contact': shippingContact,
'billing_contact': billingContact,
'token': event.payment.token,
'shipping_method': this.selectedShippingMethod,
'shipping_method': shippingMethod,
'woocommerce-process-checkout-nonce': this.nonce,
'funding_source': 'applepay',
'_wp_http_referer': '/?wc-ajax=update_order_review',
@ -491,16 +618,16 @@ class ApplepayButton {
},
success: (authorizationResult, textStatus, jqXHR) => {
this.log('onpaymentauthorized ok');
resolve(authorizationResult)
resolve(authorizationResult);
},
error: (jqXHR, textStatus, errorThrown) => {
this.log('onpaymentauthorized error', textStatus);
reject(new Error(errorThrown));
},
})
});
} catch (error) {
this.log('onpaymentauthorized catch', error);
console.log(error) // handle error
console.log(error); // handle error
}
});
}
@ -522,8 +649,8 @@ class ApplepayButton {
if (confirmOrderResponse.approveApplePayPayment.status === "APPROVED") {
try {
if (!this.contextHandler.shippingAllowed()) {
// No shipping, expect immediate capture, ex: PayNow.
if (this.shouldCompletePaymentWithContextHandler()) {
// No shipping, expect immediate capture, ex: PayNow, Checkout with form data.
let approveFailed = false;
await this.contextHandler.approveOrder({
@ -546,7 +673,7 @@ class ApplepayButton {
} else {
this.log('onpaymentauthorized approveOrder FAIL');
session.completePayment(ApplePaySession.STATUS_FAILURE);
session.abort()
session.abort();
console.error(error);
}
@ -560,17 +687,17 @@ class ApplepayButton {
};
let authorizationResult = await processInWooAndCapture(data);
if (authorizationResult.result === "success") {
session.completePayment(ApplePaySession.STATUS_SUCCESS)
window.location.href = authorizationResult.redirect
session.completePayment(ApplePaySession.STATUS_SUCCESS);
window.location.href = authorizationResult.redirect;
} else {
session.completePayment(ApplePaySession.STATUS_FAILURE)
session.completePayment(ApplePaySession.STATUS_FAILURE);
}
}
} catch (error) {
session.completePayment(ApplePaySession.STATUS_FAILURE);
session.abort()
session.abort();
console.error(error);
}
} else {
@ -584,43 +711,44 @@ class ApplepayButton {
} catch (error) {
console.error('Error confirming order with applepay token', error);
session.completePayment(ApplePaySession.STATUS_FAILURE);
session.abort()
session.abort();
}
};
}
fill_billing_contact(form_saved) {
fillBillingContact(data) {
return {
givenName: form_saved.billing_first_name ?? '',
familyName: form_saved.billing_last_name ?? '',
emailAddress: form_saved.billing_email ?? '',
phoneNumber: form_saved.billing_phone ?? '',
addressLines: [form_saved.billing_address_1, form_saved.billing_address_2],
locality: form_saved.billing_city ?? '',
postalCode: form_saved.billing_postcode ?? '',
countryCode: form_saved.billing_country ?? '',
administrativeArea: form_saved.billing_state ?? '',
}
}
fill_shipping_contact(form_saved) {
if (form_saved.shipping_first_name === "") {
return this.fill_billing_contact(form_saved)
}
return {
givenName: (form_saved?.shipping_first_name && form_saved.shipping_first_name !== "") ? form_saved.shipping_first_name : form_saved?.billing_first_name,
familyName: (form_saved?.shipping_last_name && form_saved.shipping_last_name !== "") ? form_saved.shipping_last_name : form_saved?.billing_last_name,
emailAddress: (form_saved?.shipping_email && form_saved.shipping_email !== "") ? form_saved.shipping_email : form_saved?.billing_email,
phoneNumber: (form_saved?.shipping_phone && form_saved.shipping_phone !== "") ? form_saved.shipping_phone : form_saved?.billing_phone,
addressLines: [form_saved.shipping_address_1 ?? '', form_saved.shipping_address_2 ?? ''],
locality: (form_saved?.shipping_city && form_saved.shipping_city !== "") ? form_saved.shipping_city : form_saved?.billing_city,
postalCode: (form_saved?.shipping_postcode && form_saved.shipping_postcode !== "") ? form_saved.shipping_postcode : form_saved?.billing_postcode,
countryCode: (form_saved?.shipping_country && form_saved.shipping_country !== "") ? form_saved.shipping_country : form_saved?.billing_country,
administrativeArea: (form_saved?.shipping_state && form_saved.shipping_state !== "") ? form_saved.shipping_state : form_saved?.billing_state,
givenName: data.billing_first_name ?? '',
familyName: data.billing_last_name ?? '',
emailAddress: data.billing_email ?? '',
phoneNumber: data.billing_phone ?? '',
addressLines: [data.billing_address_1, data.billing_address_2],
locality: data.billing_city ?? '',
postalCode: data.billing_postcode ?? '',
countryCode: data.billing_country ?? '',
administrativeArea: data.billing_state ?? '',
}
}
fill_application_data(form_saved) {
const jsonString = JSON.stringify(form_saved);
fillShippingContact(data) {
if (data.shipping_first_name === "") {
return this.fillBillingContact(data);
}
return {
givenName: (data?.shipping_first_name && data.shipping_first_name !== "") ? data.shipping_first_name : data?.billing_first_name,
familyName: (data?.shipping_last_name && data.shipping_last_name !== "") ? data.shipping_last_name : data?.billing_last_name,
emailAddress: (data?.shipping_email && data.shipping_email !== "") ? data.shipping_email : data?.billing_email,
phoneNumber: (data?.shipping_phone && data.shipping_phone !== "") ? data.shipping_phone : data?.billing_phone,
addressLines: [data.shipping_address_1 ?? '', data.shipping_address_2 ?? ''],
locality: (data?.shipping_city && data.shipping_city !== "") ? data.shipping_city : data?.billing_city,
postalCode: (data?.shipping_postcode && data.shipping_postcode !== "") ? data.shipping_postcode : data?.billing_postcode,
countryCode: (data?.shipping_country && data.shipping_country !== "") ? data.shipping_country : data?.billing_country,
administrativeArea: (data?.shipping_state && data.shipping_state !== "") ? data.shipping_state : data?.billing_state,
}
}
fillApplicationData(data) {
const jsonString = JSON.stringify(data);
let utf8Str = encodeURIComponent(jsonString).replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode('0x' + p1);
});