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: add stripe payment element support #257

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
23 changes: 23 additions & 0 deletions packages/api-client/src/api/addPaymentMethod/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* eslint-disable camelcase */
import { ApiContext } from '../../types';
import getCurrentBearerOrCartToken from '../authentication/getCurrentBearerOrCartToken';

export default async function addPaymentMethod({ client, config }: ApiContext, methodId: number): Promise<void> {
const token = await getCurrentBearerOrCartToken({ client, config });
const currency = await config.internationalization.getCurrency();

const result = await client.checkout.orderUpdate(token, {
order: {
payments_attributes: [
{
payment_method_id: methodId.toString()
}
]
},
currency
});

if (result.isFail()) {
throw result.fail();
}
}
28 changes: 28 additions & 0 deletions packages/api-client/src/api/getPaymentIntent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import axios from 'axios';
import { ApiContext } from '../../types';
import getCurrentBearerOrCartToken from '../authentication/getCurrentBearerOrCartToken';
import getAuthorizationHeaders from '../authentication/getAuthorizationHeaders';
import { Logger } from '@vue-storefront/core';

export default async function getPaymentIntent({ client, config }: ApiContext, methodId) {
mdavo6 marked this conversation as resolved.
Show resolved Hide resolved
try {
const token = await getCurrentBearerOrCartToken({ client, config });
const currency = await config.internationalization.getCurrency();
const endpoint = config.backendUrl.concat('/api/v2/storefront/intents/create');
const response = await axios.post(
endpoint, {
currency: currency,
payment_method_id: methodId
},
{
headers: getAuthorizationHeaders(token)
}
);
return {
clientSecret: response.data.client_secret
};
} catch (e) {
Logger.error(e);
throw e;
}
}
4 changes: 4 additions & 0 deletions packages/api-client/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiClientExtension, apiClientFactory } from '@vue-storefront/core';

import addAddress from './api/addAddress';
import addPaymentMethod from './api/addPaymentMethod';
import addToCart from './api/addToCart';
import addToWishlist from './api/addToWishlist';
import applyCoupon from './api/applyCoupon';
Expand All @@ -24,6 +25,7 @@ import getOrCreateCart from './api/getOrCreateCart';
import getOrder from './api/getOrder';
import getOrders from './api/getOrders';
import getPaymentConfirmationData from './api/getPaymentConfirmationData';
import getPaymentIntent from './api/getPaymentIntent';
import getPaymentMethods from './api/getPaymentMethods';
import getProduct from './api/getProduct';
import getProducts from './api/getProducts';
Expand Down Expand Up @@ -120,6 +122,8 @@ const { createApiClient } = apiClientFactory<any, any>({
getPaymentMethods,
savePaymentMethod,
getPaymentConfirmationData,
addPaymentMethod,
getPaymentIntent,
handlePaymentConfirmationResponse,
makeOrder,
forgotPassword,
Expand Down
83 changes: 50 additions & 33 deletions packages/theme/components/Checkout/PaymentMethod/Stripe.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
<template>
<div ref="cardRef" />
<div>
<div ref="paymentRef" />
<div
ref="errorRef"
role="alert"
class="sf-alert color-danger"
/>
</div>
</template>

<script>
import { onMounted, ref, computed } from '@nuxtjs/composition-api';
import { onMounted, ref, computed, useContext } from '@nuxtjs/composition-api';
import { useVSFContext, Logger } from '@vue-storefront/core';
import { loadStripe } from '@stripe/stripe-js';
import { useCart, orderGetters } from '@vue-storefront/spree';

export default {
props: {
Expand All @@ -16,64 +24,73 @@ export default {
},

setup(props, { emit }) {
const { $config } = useContext();
const { $spree } = useVSFContext();
const { cart } = useCart();
const stripe = ref(null);
const card = ref(null);
const cardRef = ref(null);
const areIntentsEnabled = computed(() => props.method.preferences?.intents);
const payment = ref(null);
const elements = ref(null);
const paymentRef = ref(null);
const errorRef = ref(null);
const publishableKey = computed(() => props.method.preferences?.publishable_key);

const savePayment = async () => {
try {
const methodId = props.method.id;
const { token } = await stripe.value.createToken(card.value);

const payload = {
// eslint-disable-next-line camelcase
gateway_payment_profile_id: token.id,
number: token.card.last4,
month: token.card.exp_month,
year: token.card.exp_year,
name: token.card.name
};

await $spree.api.savePaymentMethod(methodId, payload);

if (areIntentsEnabled.value) {
const threeDSecureData = await $spree.api.getPaymentConfirmationData();
const confirmCardPaymentResponse = await stripe.value.confirmCardPayment(threeDSecureData.clientSecret, {});
const handlePaymentConfirmationResponse = await $spree.api.handlePaymentConfirmationResponse({
confirmationResponse: confirmCardPaymentResponse
});

if (!handlePaymentConfirmationResponse.success) {
throw new Error('Failed to confirm payment');
const orderId = orderGetters.getId(cart.value);
const { error } = await stripe.value.confirmPayment({
elements: elements.value,
// Note: If payment is successfully confirmed, user will be redirected to the return_url
mdavo6 marked this conversation as resolved.
Show resolved Hide resolved
// and make(); will never be called in Payment.vue. Completing the order will be handled via webhook.
confirmParams: {
return_url: `${$config.baseUrl}/checkout/thank-you?order=${orderId}`
}
});

if (error) {
errorRef.value.textContent = error.message;
// Return false to prevent order proceeding to complete
return false;
}
} catch (e) {
Logger.error(e);
// Return false to prevent order proceeding to complete
return false;
}
};

const handleCardChange = (ev) => {
if (ev.error) {
errorRef.value.textContent = ev.error.message;
} else {
errorRef.value.textContent = '';
}
const isPaymentReady = ev.complete && !ev.error;
emit('change:payment', { isPaymentReady, savePayment });
};

onMounted(async () => {
try {
// Need to add payment method first to be able to create a payment intent
const methodId = props.method.id;
await $spree.api.addPaymentMethod(methodId);

const paymentIntent = await $spree.api.getPaymentIntent(methodId);

stripe.value = await loadStripe(publishableKey.value);
const elements = stripe.value.elements();
card.value = elements.create('card');
card.value.on('change', handleCardChange);
card.value.mount(cardRef.value);
elements.value = stripe.value.elements({
clientSecret: paymentIntent.clientSecret
});
payment.value = elements.value.create('payment');
payment.value.mount(paymentRef.value);
payment.value.on('change', handleCardChange);
} catch (e) {
Logger.error(e);
}
});

return {
cardRef
paymentRef,
errorRef
};
}
};
Expand Down
3 changes: 2 additions & 1 deletion packages/theme/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export default {
}
},
publicRuntimeConfig: {
theme
theme,
baseUrl: process.env.BASE_URL || 'http://localhost:3000'
}
};
16 changes: 10 additions & 6 deletions packages/theme/pages/Checkout/Payment.vue
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export default {
const isPaymentReady = ref(false);
const savePayment = ref(null);
const terms = ref(false);
const paymentSuccessful = ref(false);

onSSR(async () => {
await load();
Expand All @@ -148,18 +149,21 @@ export default {
const processOrder = async () => {
const orderId = orderGetters.getId(cart.value);
try {
await savePayment.value();
paymentSuccessful.value = await savePayment.value();
} catch (e) {
Logger.error(e);
return;
}

await make();
if (makeError.value.make) {
Logger.error(makeError.value.make);
return;
// Only complete order if payment is successful
mdavo6 marked this conversation as resolved.
Show resolved Hide resolved
if (paymentSuccessful.value) {
await make();
if (makeError.value.make) {
Logger.error(makeError.value.make);
return;
}
router.push(root.localePath(`/checkout/thank-you?order=${orderId}`));
}
router.push(root.localePath(`/checkout/thank-you?order=${orderId}`));
};

return {
Expand Down