From ed68dbb97428c90a04bdfac829e8b5330a953c63 Mon Sep 17 00:00:00 2001 From: Vladislav Studenichnik Date: Fri, 10 Nov 2023 16:01:36 +0100 Subject: [PATCH 1/2] fix: rework Stripe Google Pay using the payment request button --- src/payment/apple/stripe.js | 73 ++------ src/payment/google/stripe.js | 311 +++++++++++++++++------------------ src/utils/stripe.js | 63 +++++++ src/utils/stripe.test.js | 71 ++++++++ 4 files changed, 293 insertions(+), 225 deletions(-) diff --git a/src/payment/apple/stripe.js b/src/payment/apple/stripe.js index d33670c6..99258e0f 100644 --- a/src/payment/apple/stripe.js +++ b/src/payment/apple/stripe.js @@ -1,5 +1,5 @@ import Payment from '../payment'; -import { stripeAmountByCurrency } from '../../utils/stripe'; +import { getPaymentRequestData } from '../../utils/stripe'; import { PaymentMethodDisabledError, LibraryNotLoadedError, @@ -101,7 +101,7 @@ export default class StripeApplePayment extends Payment { requestPayerPhone: Boolean(phone), requestShipping: Boolean(shipping), disableWallets: ['googlePay', 'browserCard', 'link'], - ...this._getPaymentRequestData(cart), + ...getPaymentRequestData(cart, this.params), }); paymentRequest.on( @@ -117,65 +117,6 @@ export default class StripeApplePayment extends Payment { return paymentRequest; } - _getPaymentRequestData(cart) { - const { - currency, - shipping, - items, - capture_total, - shipment_rating, - shipment_total, - tax_included_total, - settings, - } = cart; - - const stripeCurrency = currency.toLowerCase(); - const displayItems = items.map((item) => ({ - label: item.product.name, - amount: stripeAmountByCurrency( - currency, - item.price_total - item.discount_total, - ), - })); - - if (tax_included_total) { - displayItems.push({ - label: 'Taxes', - amount: stripeAmountByCurrency(currency, tax_included_total), - }); - } - - if (shipping.price && shipment_total) { - displayItems.push({ - label: shipping.service_name, - amount: stripeAmountByCurrency(currency, shipment_total), - }); - } - - const services = shipment_rating && shipment_rating.services; - let shippingOptions; - if (services) { - shippingOptions = services.map((service) => ({ - id: service.id, - label: service.name, - detail: service.description, - amount: stripeAmountByCurrency(currency, service.price), - })); - } - - return { - country: settings.country, - currency: stripeCurrency, - total: { - label: settings.name, - amount: stripeAmountByCurrency(currency, capture_total), - pending: true, - }, - displayItems, - shippingOptions, - }; - } - async _onShippingAddressChange(event) { const { shippingAddress, updateWith } = event; const shipping = this._mapShippingAddress(shippingAddress); @@ -185,7 +126,10 @@ export default class StripeApplePayment extends Payment { }); if (cart) { - updateWith({ status: 'success', ...this._getPaymentRequestData(cart) }); + updateWith({ + status: 'success', + ...getPaymentRequestData(cart, this.params), + }); } else { updateWith({ status: 'invalid_shipping_address' }); } @@ -198,7 +142,10 @@ export default class StripeApplePayment extends Payment { }); if (cart) { - updateWith({ status: 'success', ...this._getPaymentRequestData(cart) }); + updateWith({ + status: 'success', + ...getPaymentRequestData(cart, this.params), + }); } else { updateWith({ status: 'fail' }); } diff --git a/src/payment/google/stripe.js b/src/payment/google/stripe.js index cbc61e68..f31b0d01 100644 --- a/src/payment/google/stripe.js +++ b/src/payment/google/stripe.js @@ -1,23 +1,10 @@ import Payment from '../payment'; -import { isLiveMode } from '../../utils'; +import { getPaymentRequestData } from '../../utils/stripe'; import { - PaymentMethodDisabledError, LibraryNotLoadedError, + PaymentMethodDisabledError, } from '../../utils/errors'; -const VERSION = '2018-10-31'; -const API_VERSION = 2; -const API_MINOR_VERSION = 0; -const ALLOWED_CARD_AUTH_METHODS = ['PAN_ONLY', 'CRYPTOGRAM_3DS']; -const ALLOWED_CARD_NETWORKS = [ - 'AMEX', - 'DISCOVER', - 'INTERAC', - 'JCB', - 'MASTERCARD', - 'VISA', -]; - export default class StripeGooglePayment extends Payment { constructor(request, options, params, methods) { if (!methods.card) { @@ -33,210 +20,210 @@ export default class StripeGooglePayment extends Payment { } get scripts() { - return ['google-pay']; + return ['stripe-js']; } - get google() { - if (!window.google) { - throw new LibraryNotLoadedError('Google'); - } - - return window.google; - } - - get googleClient() { - if (!StripeGooglePayment.googleClient) { - if (this.google) { - this.googleClient = new this.google.payments.api.PaymentsClient({ - environment: isLiveMode(this.method.mode) ? 'PRODUCTION' : 'TEST', - }); + get stripe() { + if (!StripeGooglePayment.stripe) { + if (window.Stripe) { + this.stripe = window.Stripe(this.method.publishable_key); } - if (!StripeGooglePayment.googleClient) { - throw new LibraryNotLoadedError('Google client'); + if (!StripeGooglePayment.stripe) { + throw new LibraryNotLoadedError('Stripe'); } } - return StripeGooglePayment.googleClient; - } - - set googleClient(googleClient) { - StripeGooglePayment.googleClient = googleClient; + return StripeGooglePayment.stripe; } - get tokenizationSpecification() { - const publishableKey = this.method.publishable_key; - - if (!publishableKey) { - throw new Error('Stripe publishable key is not defined'); - } - - return { - type: 'PAYMENT_GATEWAY', - parameters: { - gateway: 'stripe', - 'stripe:version': VERSION, - 'stripe:publishableKey': publishableKey, - }, - }; - } - - get cardPaymentMethod() { - return { - type: 'CARD', - tokenizationSpecification: this.tokenizationSpecification, - parameters: { - allowedAuthMethods: ALLOWED_CARD_AUTH_METHODS, - allowedCardNetworks: ALLOWED_CARD_NETWORKS, - billingAddressRequired: true, - billingAddressParameters: { - format: 'FULL', - phoneNumberRequired: true, - }, - }, - }; - } - - get allowedPaymentMethods() { - return [this.cardPaymentMethod]; + set stripe(stripe) { + StripeGooglePayment.stripe = stripe; } async createElements(cart) { - const { - elementId = 'googlepay-button', - locale = 'en', - style: { color = 'black', type = 'buy', sizeMode = 'fill' } = {}, - } = this.params; - - if (!this.method.merchant_id) { - throw new Error('Google merchant ID is not defined'); - } + const { elementId = 'googlepay-button', classes = {} } = this.params; this.setElementContainer(elementId); await this.loadScripts(this.scripts); - const isReadyToPay = await this.googleClient.isReadyToPay({ - apiVersion: API_VERSION, - apiVersionMinor: API_MINOR_VERSION, - allowedPaymentMethods: this.allowedPaymentMethods, - existingPaymentMethodRequired: true, - }); + const paymentRequest = this._createPaymentRequest(cart); + const canMakePayment = await paymentRequest.canMakePayment(); - if (!isReadyToPay.result) { + if (!canMakePayment || !canMakePayment.googlePay) { throw new Error( 'This device is not capable of making Google Pay payments', ); } - const paymentRequestData = this._createPaymentRequestData(cart); - - this.element = this.googleClient.createButton({ - buttonColor: color, - buttonType: type, - buttonSizeMode: sizeMode, - buttonLocale: locale, - onClick: this._onClick.bind(this, paymentRequestData), + this.element = this.stripe.elements().create('paymentRequestButton', { + paymentRequest, + style: { + paymentRequestButton: this._getButtonStyles(), + }, + classes, }); } mountElements() { - const { classes = {} } = this.params; - const container = this.elementContainer; + this.element.mount(`#${this.elementContainer.id}`); + } - container.appendChild(this.element); + _createPaymentRequest(cart) { + const { require: { name, email, shipping, phone } = {} } = this.params; - if (classes.base) { - container.classList.add(classes.base); - } + const paymentRequest = this.stripe.paymentRequest({ + requestPayerName: Boolean(name), + requestPayerEmail: Boolean(email), + requestPayerPhone: Boolean(phone), + requestShipping: Boolean(shipping), + disableWallets: ['applePay', 'browserCard', 'link'], + ...getPaymentRequestData(cart, this.params), + }); + + paymentRequest.on( + 'shippingaddresschange', + this._onShippingAddressChange.bind(this), + ); + paymentRequest.on( + 'shippingoptionchange', + this._onShippingOptionChange.bind(this), + ); + paymentRequest.on('paymentmethod', this._onPaymentMethod.bind(this)); + + return paymentRequest; } - _createPaymentRequestData(cart) { - const { - settings: { name }, - capture_total, - currency, - } = cart; - const { require: { email, shipping, phone } = {} } = this.params; + async _onShippingAddressChange(event) { + const { shippingAddress, updateWith } = event; + const shipping = this._mapShippingAddress(shippingAddress); + const cart = await this.updateCart({ + shipping: { ...shipping, service: null }, + shipment_rating: null, + }); - return { - apiVersion: API_VERSION, - apiVersionMinor: API_MINOR_VERSION, - transactionInfo: { - currencyCode: currency, - totalPrice: capture_total.toString(), - totalPriceStatus: 'ESTIMATED', - }, - allowedPaymentMethods: this.allowedPaymentMethods, - emailRequired: Boolean(email), - shippingAddressRequired: Boolean(shipping), - shippingAddressParameters: { - phoneNumberRequired: Boolean(phone), - }, - merchantInfo: { - merchantName: name, - merchantId: this.method.merchant_id, - }, - }; + if (cart) { + updateWith({ + status: 'success', + ...getPaymentRequestData(cart, this.params), + }); + } else { + updateWith({ status: 'invalid_shipping_address' }); + } } - async _onClick(paymentRequestData) { - try { - const paymentData = await this.googleClient.loadPaymentData( - paymentRequestData, - ); + async _onShippingOptionChange(event) { + const { shippingOption, updateWith } = event; + const cart = await this.updateCart({ + shipping: { service: shippingOption.id }, + }); - if (paymentData) { - await this._submitPayment(paymentData); - } - } catch (error) { - this.onError(error); + if (cart) { + updateWith({ + status: 'success', + ...getPaymentRequestData(cart, this.params), + }); + } else { + updateWith({ status: 'fail' }); } } - async _submitPayment(paymentData) { - const { require: { shipping: requireShipping } = {} } = this.params; - const { email, shippingAddress, paymentMethodData } = paymentData; + async _onPaymentMethod(event) { const { - info: { billingAddress }, - tokenizationData, - } = paymentMethodData; - const token = JSON.parse(tokenizationData.token); - const { card } = token; + payerEmail, + paymentMethod: { id: paymentMethod, card, billing_details }, + shippingAddress, + shippingOption, + complete, + } = event; + const { require: { shipping: requireShipping } = {} } = this.params; await this.updateCart({ account: { - email, + email: payerEmail, }, + ...(requireShipping && { + shipping: { + ...this._mapShippingAddress(shippingAddress), + service: shippingOption.id, + }, + }), billing: { + ...this._mapBillingAddress(billing_details), method: 'card', card: { - token: token.id, + gateway: 'stripe', + token: paymentMethod, brand: card.brand, - last4: card.last4, exp_month: card.exp_month, exp_year: card.exp_year, - gateway: 'stripe', + last4: card.last4, + address_check: card.checks.address_line1_check, + zip_check: card.checks.address_postal_code_check, + cvc_check: card.checks.cvc_check, }, - ...this._mapAddress(billingAddress), }, - ...(requireShipping && { - shipping: this._mapAddress(shippingAddress), - }), }); + complete('success'); + this.onSuccess(); } - _mapAddress(address) { + // Provides backward compatibility with Google Pay button options + // https://developers.google.com/pay/api/web/reference/request-objects#ButtonOptions + _getButtonStyles() { + let { style: { color = 'dark', type = 'default', height = '45px' } = {} } = + this.params; + + switch (color) { + case 'white': + color = 'light'; + break; + default: + color = 'dark'; + break; + } + + switch (type) { + case 'buy': + case 'donate': + break; + default: + type = 'default'; + break; + } + return { - name: address.name, - address1: address.address1, - address2: address.address2, - city: address.locality, - state: address.administrativeArea, + type, + height, + theme: color, + }; + } + + _mapShippingAddress(address = {}) { + return { + name: address.recipient, + address1: address.addressLine[0], + address2: address.addressLine[1], + city: address.city, + state: address.region, zip: address.postalCode, - country: address.countryCode, - phone: address.phoneNumber, + country: address.country, + phone: address.phone, + }; + } + + _mapBillingAddress(address = {}) { + return { + name: address.name, + phone: address.phone, + address1: address.address.line1, + address2: address.address.line2, + city: address.address.city, + state: address.address.state, + zip: address.address.postal_code, + country: address.address.country, }; } } diff --git a/src/utils/stripe.js b/src/utils/stripe.js index a6061edc..317e5efb 100644 --- a/src/utils/stripe.js +++ b/src/utils/stripe.js @@ -201,6 +201,68 @@ async function createBancontactSource(stripe, cart) { return await stripe.createSource(sourceObject); } +function getPaymentRequestData(cart, params) { + const { + currency, + shipping, + items, + capture_total, + shipment_rating, + shipment_total, + tax_included_total, + settings, + } = cart; + const { price: shippingPrice, service_name } = shipping || {}; + const { country, name } = settings || {}; + const { require: { shipping: requireShipping } = {} } = params; + + const stripeCurrency = currency.toLowerCase(); + const displayItems = items.map((item) => ({ + label: get(item, 'product.name', 'Unknown product'), + amount: stripeAmountByCurrency( + currency, + item.price_total - item.discount_total, + ), + })); + + if (tax_included_total) { + displayItems.push({ + label: 'Taxes', + amount: stripeAmountByCurrency(currency, tax_included_total), + }); + } + + if (shippingPrice && shipment_total) { + displayItems.push({ + label: service_name, + amount: stripeAmountByCurrency(currency, shipment_total), + }); + } + + let shippingOptions; + const services = get(shipment_rating, 'services'); + if (Array.isArray(services) && services.length > 0) { + shippingOptions = services.map((service) => ({ + id: service.id, + label: service.name, + detail: service.description, + amount: stripeAmountByCurrency(currency, service.price), + })); + } + + return { + country: country || 'US', + currency: stripeCurrency, + total: { + label: name || 'Swell store', + amount: stripeAmountByCurrency(currency, capture_total), + pending: true, + }, + displayItems, + ...(requireShipping && { shippingOptions }), + }; +} + function stripeAmountByCurrency(currency, amount) { const zeroDecimalCurrencies = [ 'BIF', // Burundian Franc @@ -238,6 +300,7 @@ export { getKlarnaIntentDetails, getKlarnaConfirmationDetails, createBancontactSource, + getPaymentRequestData, stripeAmountByCurrency, isStripeChargeableAmount, }; diff --git a/src/utils/stripe.test.js b/src/utils/stripe.test.js index 574a9728..74a23ea4 100644 --- a/src/utils/stripe.test.js +++ b/src/utils/stripe.test.js @@ -3,6 +3,7 @@ import { createIDealPaymentMethod, getKlarnaIntentDetails, getKlarnaConfirmationDetails, + getPaymentRequestData, } from './stripe'; describe('utils/stripe', () => { @@ -288,4 +289,74 @@ describe('utils/stripe', () => { }); }); }); + + describe('#getPaymentRequestData', () => { + let cart = {}; + let params = {}; + + beforeEach(() => { + cart = { + currency: 'USD', + capture_total: 15, + tax_included_total: 4, + shipment_total: 3, + shipping: { + price: 3, + service_name: 'standard', + }, + items: [ + { + product: { + name: 'Test product', + }, + price_total: 10, + discount_total: 2, + }, + ], + shipment_rating: { + services: [ + { + id: 'standard', + name: 'Standard', + description: 'Standard service', + price: 3, + }, + ], + }, + settings: { + country: 'CA', + name: 'Test store', + }, + }; + + params = { + require: { + shipping: true, + }, + }; + }); + + it('should return payment request data', () => { + const result = getPaymentRequestData(cart, params); + + expect(result).toEqual({ + country: 'CA', + currency: 'usd', + total: { label: 'Test store', amount: 1500, pending: true }, + displayItems: [ + { label: 'Test product', amount: 800 }, + { label: 'Taxes', amount: 400 }, + { label: 'standard', amount: 300 }, + ], + shippingOptions: [ + { + id: 'standard', + label: 'Standard', + detail: 'Standard service', + amount: 300, + }, + ], + }); + }); + }); }); From 27d51e6dbdc25adc9ecd0afbbe01b4db89a501c6 Mon Sep 17 00:00:00 2001 From: Vladislav Studenichnik Date: Wed, 31 Jan 2024 17:13:23 +0100 Subject: [PATCH 2/2] chore: code improvements --- src/payment/google/stripe.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/payment/google/stripe.js b/src/payment/google/stripe.js index f31b0d01..b686b40d 100644 --- a/src/payment/google/stripe.js +++ b/src/payment/google/stripe.js @@ -50,7 +50,7 @@ export default class StripeGooglePayment extends Payment { const paymentRequest = this._createPaymentRequest(cart); const canMakePayment = await paymentRequest.canMakePayment(); - if (!canMakePayment || !canMakePayment.googlePay) { + if (!canMakePayment?.googlePay) { throw new Error( 'This device is not capable of making Google Pay payments', );