Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improve Stripe add payment option #207

Open
wants to merge 34 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
00483ae
Load Stripe async
alexookah Jul 24, 2024
f9f5df7
Remove un-necessary type
alexookah Jul 24, 2024
0771916
Move button text in template
alexookah Jul 24, 2024
912dd41
Make isCheckoutDisabled based on the stripe isLoaded
alexookah Jul 25, 2024
ad5539b
Add order failed translation
alexookah Jul 25, 2024
27d926c
fix german translation
alexookah Jul 25, 2024
1bb49db
Add payment stripe, support redirect state
alexookah Jul 25, 2024
7e0bbaf
fix: metaData of type null breaks checkout
scottyzen Jul 25, 2024
7938e85
Throw error if stripePaymentIntent.id is missing
alexookah Jul 25, 2024
a150d7f
Change setup -> payment
alexookah Jul 26, 2024
ca42ef9
First validate stripe elements
alexookah Jul 27, 2024
cbbc7d8
Check redirect status inside try
alexookah Jul 27, 2024
e3eb8ce
Update elements when cart change
alexookah Jul 27, 2024
eab5ef1
Check faster if payment succeeded from redirect!
alexookah Jul 27, 2024
995eb80
Check for requires_payment_method status
alexookah Jul 29, 2024
cc50e20
Move initStripe in onBeforeMount
alexookah Jul 29, 2024
a56da8f
Use StripePaymentMethodEnum
alexookah Jul 29, 2024
b69e932
Add card as default payment method
alexookah Jul 29, 2024
1467d69
Remove unused imports
alexookah Jul 29, 2024
dd6a531
Improve error checking for card checkout
alexookah Jul 31, 2024
c386360
Add CheckoutInlineError & error message for validationErrors
alexookah Jul 31, 2024
f2fd009
Lint fixes
alexookah Jul 31, 2024
74452f7
Merge branch 'main' into stripe_payment
alexookah Jul 31, 2024
cf8e60d
Reuse errorMessage for validateStripePayment after redirect
alexookah Jul 31, 2024
187f651
Merge branch 'main' into stripe_payment
alexookah Aug 13, 2024
1fd595b
Merge branch 'main' into stripe_payment
alexookah Aug 19, 2024
ce923d0
Merge branch 'main' into stripe_payment
alexookah Aug 19, 2024
d360495
Merge branch 'main' into stripe_payment
alexookah Aug 19, 2024
47bc948
Merge branch 'main' into stripe_payment
alexookah Aug 25, 2024
614fba6
Merge branch 'main' into stripe_payment
alexookah Aug 26, 2024
06ea171
Merge branch 'main' into stripe_payment
alexookah Sep 1, 2024
a2c6af5
Merge branch 'main' into stripe_payment
alexookah Sep 10, 2024
7473beb
Merge branch 'main' into stripe_payment
alexookah Sep 21, 2024
b4a99e3
Merge branch 'main' into stripe_payment
alexookah Nov 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions woonuxt_base/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default defineAppConfig({
showBreadcrumbOnSingleProduct: true,
showMoveToWishlist: true,
hideBillingAddressForVirtualProducts: false,
stripePaymentMethod: 'card', // 'card' || 'payment' -> ( 'card': shows CardElement, 'payment': shows payment tabs from stripe )
initStoreOnUserActionToReduceServerLoad: true,
saleBadge: 'percent', // 'percent', 'onSale' or 'hidden'
},
Expand Down
39 changes: 31 additions & 8 deletions woonuxt_base/app/components/shopElements/StripeElement.vue
Original file line number Diff line number Diff line change
@@ -1,32 +1,55 @@
<script setup lang="ts">
import type { Stripe, StripeElements, StripeElementsOptionsMode } from '@stripe/stripe-js';

const { cart } = useCart();
const { stripe } = defineProps(['stripe']);
const { storeSettings } = useAppConfig();

const { stripe } = defineProps({
stripe: { type: Object as PropType<Stripe>, default: null, required: true },
});

const rawCartTotal = computed(() => cart.value && parseFloat(cart.value.rawTotal as string) * 100);
const emit = defineEmits(['updateElement']);
let elements = null as any;
let elements: StripeElements | null = null;

const options = {
const options: StripeElementsOptionsMode = {
mode: 'payment',
currency: 'eur',
amount: rawCartTotal.value,
// paymentMethodCreation: 'manual',
amount: rawCartTotal.value || 0,
};

const createStripeElements = async () => {
elements = stripe.elements(options);
const paymentElement = elements.create('card', { hidePostalCode: true });
paymentElement.mount('#card-element');

if (storeSettings.stripePaymentMethod === 'payment') {
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
} else {
const paymentElement = elements.create('card', { hidePostalCode: true });
paymentElement.mount('#card-element');
}

emit('updateElement', elements);
};

const updateStripeElements = async () => {
elements?.update({ amount: rawCartTotal.value || 0 });
};

onMounted(() => {
createStripeElements();
});

watch(rawCartTotal, (newAmount) => {
updateStripeElements();
});
</script>

<template>
<div id="card-element">
<div v-if="storeSettings.stripePaymentMethod === 'payment'" id="payment-element">
<!-- Elements will create form elements here -->
</div>
<div v-else id="card-element">
<!-- Elements will create form elements here -->
</div>
</template>
7 changes: 7 additions & 0 deletions woonuxt_base/app/composables/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export const useAuth = () => {
const orders = useState<Order[] | null>('orders', () => null);
const downloads = useState<DownloadableItem[] | null>('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;
Expand Down
167 changes: 155 additions & 12 deletions woonuxt_base/app/composables/useCheckout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { CheckoutInput, UpdateCustomerInput, CreateAccountInput } from '#gql';
import { StripePaymentMethodEnum } from '#gql/default';
import type { CreateSourceData, Stripe, StripeCardElement, StripeElements } from '@stripe/stripe-js';
import { CheckoutInlineError } from '../types/CheckoutInlineError';

export function useCheckout() {
const { t } = useI18n();
const { storeSettings } = useAppConfig();
const errorMessage = useState<string | null>('errorMessage', () => null);
const orderInput = useState<any>('orderInput', () => {
return {
customerNote: '',
Expand All @@ -10,6 +16,13 @@ export function useCheckout() {
};
});

onMounted(() => {
const savedOrderInput = localStorage.getItem('WooNuxtOrderInput');
if (savedOrderInput) {
orderInput.value = JSON.parse(savedOrderInput);
}
});

const isProcessingOrder = useState<boolean>('isProcessingOrder', () => false);

// if Country or State are changed, calculate the shipping rates again
Expand Down Expand Up @@ -88,11 +101,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 ?? '';

Expand All @@ -112,34 +125,164 @@ export function useCheckout() {
router.push(`/checkout/order-received/${orderId}/?key=${orderKey}`);
}

if ((await checkout?.result) !== 'success') {
alert('There was an error processing your order. Please try again.');
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);
}
} finally {
manageCheckoutLocalStorage(false);
isProcessingOrder.value = false;
}
};

const stripeCheckout = async (stripe: Stripe, elements: StripeElements) => {
let isPaid: boolean;

if (storeSettings.stripePaymentMethod === 'card') {
isPaid = await stripeCardCheckout(stripe, elements);

} else if (storeSettings.stripePaymentMethod === 'payment') {
isPaid = await stripePaymentCheckout(stripe, elements);
} else {
throw new Error("Invalid storeSettings.stripePaymentMethod");
}

if (isPaid) {
await proccessCheckout(true);
} else {
throw new Error(t('messages.error.orderFailed'));
}
}

const stripeCardCheckout = async (stripe: Stripe, elements: StripeElements) => {
const cardElement = elements.getElement('card') as StripeCardElement;
const { stripePaymentIntent } = await GqlGetStripePaymentIntent({ stripePaymentMethod: StripePaymentMethodEnum.SETUP });
const clientSecret = stripePaymentIntent?.clientSecret;
if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!');

const { setupIntent, error } = await stripe.confirmCardSetup(clientSecret, { payment_method: { card: cardElement } });
if (error) {
throw new CheckoutInlineError(error.message);
}

const { source } = await stripe.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 });

orderInput.value.transactionId = setupIntent?.id || stripePaymentIntent.id;

return setupIntent?.status === 'succeeded' || false;
};

const stripePaymentCheckout = async (stripe: Stripe, elements: StripeElements) => {

alert(errorMessage);
return null;
const { error: submitError } = await elements.submit();
if (submitError) {
throw new CheckoutInlineError(submitError.message);
}

isProcessingOrder.value = false;
const { stripePaymentIntent } = await GqlGetStripePaymentIntent({ stripePaymentMethod: StripePaymentMethodEnum.PAYMENT });
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;

// 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 { paymentIntent, error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/checkout`,
},
redirect: 'if_required',
});

if (error) {
throw new CheckoutInlineError(error.message);
}

return paymentIntent.status === 'succeeded' || false;
};

const validateStripePaymentFromRedirect = async (stripe: Stripe, clientSecret: string, redirectStatus: string) => {
try {
if (redirectStatus !== 'succeeded') throw new CheckoutInlineError(t('messages.error.paymentFailed'));

isProcessingOrder.value = true;
const { paymentIntent, error } = await stripe.retrievePaymentIntent(clientSecret);
if (error) {
throw new Error(error.message);
}

switch (paymentIntent?.status) {
case "succeeded":
await proccessCheckout(true);
break;
case "processing":
await proccessCheckout(false);
break;
case "requires_payment_method":
// If the payment attempt fails (for example due to a decline),
// the PaymentIntent’s status returns to requires_payment_method so that the payment can be retried.
throw new CheckoutInlineError(t('messages.error.paymentFailed'));
default:
throw new Error("Something went wrong. ('" + paymentIntent?.status + "')");
}
} catch (error: any) {
isProcessingOrder.value = false;
console.error(error);

useRouter().push({ query: {} });
manageCheckoutLocalStorage(false);

if (error instanceof CheckoutInlineError) {
errorMessage.value = error.message;
} else {
alert(error);
}
}
};

/**
* 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,
errorMessage,
stripeCheckout,
validateStripePaymentFromRedirect,
proccessCheckout,
updateShippingLocation,
};
Expand Down
2 changes: 2 additions & 0 deletions woonuxt_base/app/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
"invalidPassword": "Ungültiges Passwort. Bitte versuchen Sie es erneut.",
"passwordMismatch": "Die Passwörter stimmen nicht überein. Bitte versuche es erneut.",
"invalidPasswordResetLink": "Der Passwort-Reset-Link ist ungültig.",
"orderFailed": "Bei der Bearbeitung Ihrer Bestellung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"paymentFailed": "Zahlung fehlgeschlagen. Bitte versuchen Sie es mit einer anderen Zahlungsmethode.",
"general": "Etwas ist schief gelaufen",
"noOrder": "Wir konnten Ihre Bestellung nicht finden. Bitte versuchen Sie es später noch einmal."
}
Expand Down
2 changes: 2 additions & 0 deletions woonuxt_base/app/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
"incorrectPassword": "Incorrect password. Please try again.",
"passwordMismatch": "Passwords do not match. Please try again.",
"invalidPasswordResetLink": "Password reset link is invalid.",
"orderFailed": "There was an error processing your order. Please try again.",
"paymentFailed": "Payment failed. Please try another payment method.",
"general": "Something went wrong",
"noOrder": "We could not find your order. Please try again later."
}
Expand Down
2 changes: 2 additions & 0 deletions woonuxt_base/app/locales/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
"invalidPassword": "Contraseña inválida. Por favor, inténtalo de nuevo.",
"passwordMismatch": "Las contraseñas no coinciden. Por favor, inténtalo de nuevo.",
"invalidPasswordResetLink": "El enlace de restablecimiento de contraseña no es válido.",
"orderFailed": "Hubo un error al procesar su pedido. Por favor, inténtelo de nuevo.",
"paymentFailed": "El pago ha fallado. Por favor, intenta con otro método de pago.",
"general": "Ha ocurrido un error",
"noOrder": "No hemos podido encontrar tu pedido. Por favor, inténtalo de nuevo más tarde."
}
Expand Down
2 changes: 2 additions & 0 deletions woonuxt_base/app/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
"incorrectPassword": "Mot de passe invalide. Veuillez réessayer.",
"passwordMismatch": "Les mots de passe ne correspondent pas. Veuillez réessayer.",
"invalidPasswordResetLink": "Le lien de réinitialisation du mot de passe est invalide.",
"orderFailed": "Une erreur s'est produite lors du traitement de votre commande. Veuillez réessayer.",
"paymentFailed": "Le paiement a échoué. Veuillez essayer un autre mode de paiement.",
"general": "Une erreur est survenue",
"noOrder": "Impossible de trouver votre commande. Veuillez réessayer plus tard."
}
Expand Down
2 changes: 2 additions & 0 deletions woonuxt_base/app/locales/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
"incorrectPassword": "Password errata. Riprova.",
"passwordMismatch": "Le password non coincidono. Per favore riprova.",
"invalidPasswordResetLink": "Il link per reimpostare la password non è valido.",
"orderFailed": "Si è verificato un errore durante l'elaborazione del tuo ordine. Per favore, riprova.",
"paymentFailed": "Pagamento fallito. Per favore, prova un altro metodo di pagamento.",
"general": "Qualcosa è andato storto",
"noOrder": "Non abbiamo trovato il tuo ordine. Riprova più tardi."
}
Expand Down
2 changes: 2 additions & 0 deletions woonuxt_base/app/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@
"incorrectPassword": "Senha incorreta. Por favor, tente novamente.",
"passwordMismatch": "As senhas não coincidem. Por favor, tente novamente.",
"invalidPasswordResetLink": "O link de redefinição de senha é inválido.",
"orderFailed": "Houve um erro ao processar seu pedido. Por favor, tente novamente.",
"paymentFailed": "Pagamento falhou. Por favor, tente outro método de pagamento.",
"general": "Algo deu errado",
"noOrder": "Não conseguimos encontrar seu pedido. Por favor, tente novamente mais tarde."
}
Expand Down
Loading