From 00483ae9b27d20f05948ffa72e8bb41aa8133732 Mon Sep 17 00:00:00 2001 From: Alex Lykesas Date: Thu, 25 Jul 2024 02:19:32 +0300 Subject: [PATCH 01/23] Load Stripe async --- .../components/shopElements/StripeElement.vue | 17 ++++--- woonuxt_base/app/pages/checkout.vue | 45 ++++++++++++------- woonuxt_base/app/types/index.d.ts | 1 + 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/woonuxt_base/app/components/shopElements/StripeElement.vue b/woonuxt_base/app/components/shopElements/StripeElement.vue index ebb04793..19aac0a2 100644 --- a/woonuxt_base/app/components/shopElements/StripeElement.vue +++ b/woonuxt_base/app/components/shopElements/StripeElement.vue @@ -1,22 +1,27 @@ diff --git a/woonuxt_base/app/composables/useAuth.ts b/woonuxt_base/app/composables/useAuth.ts index f242f38d..64b01b21 100644 --- a/woonuxt_base/app/composables/useAuth.ts +++ b/woonuxt_base/app/composables/useAuth.ts @@ -11,6 +11,13 @@ export const useAuth = () => { const orders = useState('orders', () => null); const downloads = useState('downloads', () => null); + onMounted(() => { + const savedCustomer = localStorage.getItem('WooNuxtCustomer'); + if (savedCustomer) { + customer.value = JSON.parse(savedCustomer); + } + }); + // Log in the user const loginUser = async (credentials: CreateAccountInput): Promise<{ success: boolean; error: any }> => { isPending.value = true; diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index e28b7852..05d6bd52 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -1,6 +1,8 @@ import type { CheckoutInput, UpdateCustomerInput, CreateAccountInput } from '#gql'; +import type { Stripe, StripeElements } from '@stripe/stripe-js'; export function useCheckout() { + const { t } = useI18n(); const orderInput = useState('orderInput', () => { return { customerNote: '', @@ -10,6 +12,13 @@ export function useCheckout() { }; }); + onMounted(() => { + const savedOrderInput = localStorage.getItem('WooNuxtOrderInput'); + if (savedOrderInput) { + orderInput.value = JSON.parse(savedOrderInput); + } + }); + const isProcessingOrder = useState('isProcessingOrder', () => false); // if Country or State are changed, calculate the shipping rates again @@ -88,11 +97,11 @@ export function useCheckout() { const orderId = checkout?.order?.databaseId; const orderKey = checkout?.order?.orderKey; - const orderInputPaymentId = orderInput.value.paymentMethod.id; - const isPayPal = orderInputPaymentId === 'paypal' || orderInputPaymentId === 'ppcp-gateway'; + const paymentMethodId = orderInput.value.paymentMethod.id; + const isPayPal = paymentMethodId === 'paypal' || paymentMethodId === 'ppcp-gateway'; // PayPal redirect - if ((await checkout?.redirect) && isPayPal) { + if ((checkout?.redirect) && isPayPal) { const frontEndUrl = window.location.origin; let redirectUrl = checkout?.redirect ?? ''; @@ -112,34 +121,112 @@ export function useCheckout() { router.push(`/checkout/order-received/${orderId}/?key=${orderKey}`); } - if ((await checkout?.result) !== 'success') { + if (checkout?.result !== 'success') { alert(t('messages.error.orderFailed')); window.location.reload(); - return checkout; } else { await emptyCart(); await refreshCart(); } } catch (error: any) { - isProcessingOrder.value = false; - const errorMessage = error?.gqlErrors?.[0].message; if (errorMessage?.includes('An account is already registered with your email address')) { alert('An account is already registered with your email address'); - return null; + } else { + alert(errorMessage); } - alert(errorMessage); - return null; + } finally { + manageCheckoutLocalStorage(false); + isProcessingOrder.value = false; + } + }; + + const stripePaymentCheckout = async (stripe: Stripe, elements: StripeElements) => { + + const { stripePaymentIntent } = await GqlGetStripePaymentIntent(); + const clientSecret = stripePaymentIntent?.clientSecret; + if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); + + const { error: submitError } = await elements.submit(); + if (submitError) { + throw new Error(submitError.message); + } + + orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); + orderInput.value.transactionId = stripePaymentIntent.id; + + // Let's save checkout orderInput & customer to maintain state after redirect + // We are not sure whether the confirmSetup will redirect if needed or continue code execution + manageCheckoutLocalStorage(true); + + const confirmSetup = await stripe.confirmSetup({ + elements, + clientSecret, + confirmParams: { + return_url: `${window.location.origin}/checkout`, + }, + redirect: 'if_required', + }); + + if (confirmSetup.error) { + throw new Error(confirmSetup.error.message); } - isProcessingOrder.value = false; + if (confirmSetup.setupIntent.status === 'succeeded') { + proccessCheckout(true); + } + } + + const validateStripePaymentFromRedirect = async (stripe: Stripe, clientSecret: string, redirectStatus: string) => { + + const clear = () => { + useRouter().push({ query: {} }); + manageCheckoutLocalStorage(false); + alert(t('messages.error.orderFailed')); + } + + if (redirectStatus !== 'succeeded') { + clear(); + return; + } + + isProcessingOrder.value = true; + try { + const paymentIntent = await stripe.retrieveSetupIntent(clientSecret); + if (paymentIntent?.setupIntent?.status === 'succeeded') { + proccessCheckout(true); + } + } catch (error) { + isProcessingOrder.value = false; + console.error(error); + clear(); + } }; + /** + * Manages the local storage for checkout data, specifically saving and removing + * the 'WooNuxtOrderInput' and 'WooNuxtCustomer' items. This is necessary to maintain + * the state after a redirect, ensuring the orderInput and customer information persist. + * + * @param {boolean} shouldStore - Indicates whether to save or remove the data in local storage. + */ + const manageCheckoutLocalStorage = (shouldStore: boolean) => { + if (shouldStore) { + localStorage.setItem('WooNuxtOrderInput', JSON.stringify(orderInput.value)); + localStorage.setItem('WooNuxtCustomer', JSON.stringify(useAuth().customer.value)); + } else { + localStorage.removeItem('WooNuxtOrderInput'); + localStorage.removeItem('WooNuxtCustomer'); + } + } + return { orderInput, isProcessingOrder, + stripePaymentCheckout, + validateStripePaymentFromRedirect, proccessCheckout, updateShippingLocation, }; diff --git a/woonuxt_base/app/pages/checkout.vue b/woonuxt_base/app/pages/checkout.vue index a2199989..38152131 100644 --- a/woonuxt_base/app/pages/checkout.vue +++ b/woonuxt_base/app/pages/checkout.vue @@ -6,7 +6,7 @@ const { t } = useI18n(); const { query } = useRoute(); const { cart, isUpdatingCart, paymentGateways } = useCart(); const { customer, viewer } = useAuth(); -const { orderInput, isProcessingOrder, proccessCheckout } = useCheckout(); +const { orderInput, isProcessingOrder, proccessCheckout, stripePaymentCheckout, validateStripePaymentFromRedirect } = useCheckout(); const runtimeConfig = useRuntimeConfig(); const isCheckoutDisabled = computed( @@ -14,16 +14,19 @@ const isCheckoutDisabled = computed( isProcessingOrder.value || isUpdatingCart.value || !orderInput.value.paymentMethod || - (orderInput.value.paymentMethod.id === 'stripe' && (!stripe.value || !elements.value)) + (orderInput.value.paymentMethod.id === 'stripe' && !stripeElementsLoaded.value) ); const isInvalidEmail = ref(false); const stripe = ref(null); const elements = ref(null); -const isPaid = ref(false); +const stripeElementsLoaded = ref(false); onBeforeMount(async () => { if (query.cancel_order) window.close(); +}); + +onMounted(() => { initStripe(); }); @@ -36,6 +39,12 @@ const initStripe = async () => { const handleStripeElement = (stripeElements: StripeElements): void => { elements.value = stripeElements; + + // Wait for stripe elements to load and check if we came back from redirect! + stripeElements.getElement('payment')?.on('ready', () => { + stripeElementsLoaded.value = true; + checkSetupIntentStatusFromRedirect(); + }); }; const payNow = async () => { @@ -45,29 +54,24 @@ const payNow = async () => { isProcessingOrder.value = true; if (orderInput.value.paymentMethod.id === 'stripe') { - if (!stripe.value || !elements.value) return; - - const { stripePaymentIntent } = await GqlGetStripePaymentIntent(); - const clientSecret = stripePaymentIntent?.clientSecret; - if (!clientSecret) throw new Error('Payment intent client secret missing!'); - - const cardElement = elements.value.getElement('card') as StripeCardElement; - const { setupIntent } = await stripe.value.confirmCardSetup(clientSecret, { payment_method: { card: cardElement } }); - const { source } = await stripe.value.createSource(cardElement as CreateSourceData); - - if (source) orderInput.value.metaData.push({ key: '_stripe_source_id', value: source.id }); - if (setupIntent) orderInput.value.metaData.push({ key: '_stripe_intent_id', value: setupIntent.id }); - - isPaid.value = setupIntent?.status === 'succeeded' || false; - orderInput.value.transactionId = source?.created?.toString() || new Date().getTime().toString(); + if (!stripe.value || !elements.value || !stripeElementsLoaded) throw new Error('Stripe not ready!'); + await stripePaymentCheckout(stripe.value, elements.value); + } else { + await proccessCheckout(false); } - proccessCheckout(isPaid.value); } catch (error) { console.error(error); isProcessingOrder.value = false; } }; +const checkSetupIntentStatusFromRedirect = async () => { + const clientSecret = query.setup_intent_client_secret as string; + const redirectStatus = query.redirect_status as string; + if (!stripe.value || !elements.value || !stripeElementsLoaded || !clientSecret || !redirectStatus) return; + await validateStripePaymentFromRedirect(stripe.value, clientSecret, redirectStatus); +}; + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/; const checkEmailOnBlur = (email?: string | null): void => { From 7e0bbaf09a8344277198584922d9153b8ee604f6 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 25 Jul 2024 22:45:11 +0100 Subject: [PATCH 08/23] fix: metaData of type null breaks checkout --- woonuxt_base/app/composables/useCheckout.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index 05d6bd52..5fe7b36a 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -101,7 +101,7 @@ export function useCheckout() { const isPayPal = paymentMethodId === 'paypal' || paymentMethodId === 'ppcp-gateway'; // PayPal redirect - if ((checkout?.redirect) && isPayPal) { + if (checkout?.redirect && isPayPal) { const frontEndUrl = window.location.origin; let redirectUrl = checkout?.redirect ?? ''; @@ -136,7 +136,6 @@ export function useCheckout() { } else { alert(errorMessage); } - } finally { manageCheckoutLocalStorage(false); isProcessingOrder.value = false; @@ -144,7 +143,6 @@ export function useCheckout() { }; const stripePaymentCheckout = async (stripe: Stripe, elements: StripeElements) => { - const { stripePaymentIntent } = await GqlGetStripePaymentIntent(); const clientSecret = stripePaymentIntent?.clientSecret; if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); @@ -154,8 +152,10 @@ export function useCheckout() { throw new Error(submitError.message); } - orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); - orderInput.value.transactionId = stripePaymentIntent.id; + if (stripePaymentIntent?.id) { + orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); + orderInput.value.transactionId = stripePaymentIntent.id; + } // Let's save checkout orderInput & customer to maintain state after redirect // We are not sure whether the confirmSetup will redirect if needed or continue code execution @@ -177,15 +177,14 @@ export function useCheckout() { if (confirmSetup.setupIntent.status === 'succeeded') { proccessCheckout(true); } - } + }; const validateStripePaymentFromRedirect = async (stripe: Stripe, clientSecret: string, redirectStatus: string) => { - const clear = () => { useRouter().push({ query: {} }); manageCheckoutLocalStorage(false); alert(t('messages.error.orderFailed')); - } + }; if (redirectStatus !== 'succeeded') { clear(); @@ -206,7 +205,7 @@ export function useCheckout() { }; /** - * Manages the local storage for checkout data, specifically saving and removing + * Manages the local storage for checkout data, specifically saving and removing * the 'WooNuxtOrderInput' and 'WooNuxtCustomer' items. This is necessary to maintain * the state after a redirect, ensuring the orderInput and customer information persist. * @@ -220,7 +219,7 @@ export function useCheckout() { localStorage.removeItem('WooNuxtOrderInput'); localStorage.removeItem('WooNuxtCustomer'); } - } + }; return { orderInput, From 7938e856f0cda95703274cef1d3cae93e9c9c7dd Mon Sep 17 00:00:00 2001 From: Alex Lykesas Date: Fri, 26 Jul 2024 02:54:47 +0300 Subject: [PATCH 09/23] Throw error if stripePaymentIntent.id is missing --- woonuxt_base/app/composables/useCheckout.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index 5fe7b36a..122131ce 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -146,16 +146,15 @@ export function useCheckout() { const { stripePaymentIntent } = await GqlGetStripePaymentIntent(); const clientSecret = stripePaymentIntent?.clientSecret; if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); + if (!stripePaymentIntent.id) throw new Error('Stripe PaymentIntent id missing!'); const { error: submitError } = await elements.submit(); if (submitError) { throw new Error(submitError.message); } - if (stripePaymentIntent?.id) { - orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); - orderInput.value.transactionId = stripePaymentIntent.id; - } + orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); + orderInput.value.transactionId = stripePaymentIntent.id; // Let's save checkout orderInput & customer to maintain state after redirect // We are not sure whether the confirmSetup will redirect if needed or continue code execution From a150d7fd5c9a8bdd0ac0b9e7f383c583bcf054e2 Mon Sep 17 00:00:00 2001 From: Alex Lykesas Date: Fri, 26 Jul 2024 03:48:20 +0300 Subject: [PATCH 10/23] Change setup -> payment --- .../app/components/shopElements/StripeElement.vue | 3 ++- woonuxt_base/app/composables/useCheckout.ts | 10 +++++----- woonuxt_base/app/pages/checkout.vue | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/woonuxt_base/app/components/shopElements/StripeElement.vue b/woonuxt_base/app/components/shopElements/StripeElement.vue index 2e059c44..0b81d65a 100644 --- a/woonuxt_base/app/components/shopElements/StripeElement.vue +++ b/woonuxt_base/app/components/shopElements/StripeElement.vue @@ -12,8 +12,9 @@ const emit = defineEmits(['updateElement']); let elements: StripeElements | null = null; const options: StripeElementsOptionsMode = { - mode: 'setup', + mode: 'payment', currency: 'eur', + amount: rawCartTotal.value || 0, }; const createStripeElements = async () => { diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index 122131ce..b4ef2a66 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -160,7 +160,7 @@ export function useCheckout() { // We are not sure whether the confirmSetup will redirect if needed or continue code execution manageCheckoutLocalStorage(true); - const confirmSetup = await stripe.confirmSetup({ + const confirmSetup = await stripe.confirmPayment({ elements, clientSecret, confirmParams: { @@ -173,7 +173,7 @@ export function useCheckout() { throw new Error(confirmSetup.error.message); } - if (confirmSetup.setupIntent.status === 'succeeded') { + if (confirmSetup.paymentIntent.status === 'succeeded') { proccessCheckout(true); } }; @@ -189,11 +189,11 @@ export function useCheckout() { clear(); return; } - + isProcessingOrder.value = true; try { - const paymentIntent = await stripe.retrieveSetupIntent(clientSecret); - if (paymentIntent?.setupIntent?.status === 'succeeded') { + const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); + if (paymentIntent?.paymentIntent?.status === 'succeeded') { proccessCheckout(true); } } catch (error) { diff --git a/woonuxt_base/app/pages/checkout.vue b/woonuxt_base/app/pages/checkout.vue index 38152131..1e1d6d02 100644 --- a/woonuxt_base/app/pages/checkout.vue +++ b/woonuxt_base/app/pages/checkout.vue @@ -62,11 +62,12 @@ const payNow = async () => { } catch (error) { console.error(error); isProcessingOrder.value = false; + alert(error); } }; const checkSetupIntentStatusFromRedirect = async () => { - const clientSecret = query.setup_intent_client_secret as string; + const clientSecret = query.payment_intent_client_secret as string; const redirectStatus = query.redirect_status as string; if (!stripe.value || !elements.value || !stripeElementsLoaded || !clientSecret || !redirectStatus) return; await validateStripePaymentFromRedirect(stripe.value, clientSecret, redirectStatus); From ca42ef9d768d1f767d8c90cc4801840ee796aa8f Mon Sep 17 00:00:00 2001 From: Alex Lykesas Date: Sat, 27 Jul 2024 18:32:20 +0300 Subject: [PATCH 11/23] First validate stripe elements --- woonuxt_base/app/composables/useCheckout.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index b4ef2a66..62bebba3 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -143,16 +143,17 @@ export function useCheckout() { }; const stripePaymentCheckout = async (stripe: Stripe, elements: StripeElements) => { - const { stripePaymentIntent } = await GqlGetStripePaymentIntent(); - const clientSecret = stripePaymentIntent?.clientSecret; - if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); - if (!stripePaymentIntent.id) throw new Error('Stripe PaymentIntent id missing!'); const { error: submitError } = await elements.submit(); if (submitError) { throw new Error(submitError.message); } + const { stripePaymentIntent } = await GqlGetStripePaymentIntent(); + const clientSecret = stripePaymentIntent?.clientSecret; + if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); + if (!stripePaymentIntent.id) throw new Error('Stripe PaymentIntent id missing!'); + orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); orderInput.value.transactionId = stripePaymentIntent.id; From cbbc7d81f87bc3b37cdb865b69a4bfa0c7d66558 Mon Sep 17 00:00:00 2001 From: Alex Lykesas Date: Sat, 27 Jul 2024 18:43:49 +0300 Subject: [PATCH 12/23] Check redirect status inside try --- woonuxt_base/app/composables/useCheckout.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index 62bebba3..57f8c443 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -180,19 +180,10 @@ export function useCheckout() { }; const validateStripePaymentFromRedirect = async (stripe: Stripe, clientSecret: string, redirectStatus: string) => { - const clear = () => { - useRouter().push({ query: {} }); - manageCheckoutLocalStorage(false); - alert(t('messages.error.orderFailed')); - }; - - if (redirectStatus !== 'succeeded') { - clear(); - return; - } - - isProcessingOrder.value = true; try { + if (redirectStatus !== 'succeeded') throw new Error('Redirect status not suceeded'); + + isProcessingOrder.value = true; const paymentIntent = await stripe.retrievePaymentIntent(clientSecret); if (paymentIntent?.paymentIntent?.status === 'succeeded') { proccessCheckout(true); @@ -200,7 +191,10 @@ export function useCheckout() { } catch (error) { isProcessingOrder.value = false; console.error(error); - clear(); + + useRouter().push({ query: {} }); + manageCheckoutLocalStorage(false); + alert(t('messages.error.orderFailed')); } }; From e3eb8cec402caaf5eef979ed01c3f060c804aec6 Mon Sep 17 00:00:00 2001 From: Alex Lykesas Date: Sat, 27 Jul 2024 19:33:15 +0300 Subject: [PATCH 13/23] Update elements when cart change --- .../app/components/shopElements/StripeElement.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/woonuxt_base/app/components/shopElements/StripeElement.vue b/woonuxt_base/app/components/shopElements/StripeElement.vue index 0b81d65a..a738462a 100644 --- a/woonuxt_base/app/components/shopElements/StripeElement.vue +++ b/woonuxt_base/app/components/shopElements/StripeElement.vue @@ -24,9 +24,17 @@ const createStripeElements = async () => { emit('updateElement', elements); }; +const updateStripeElements = async () => { + elements?.update({ amount: rawCartTotal.value || 0 }); +}; + onMounted(() => { createStripeElements(); }); + +watch(rawCartTotal, (newAmount) => { + updateStripeElements(); +});