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 all 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, PaymentIntent } 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: number): Promise<PaymentIntent> {
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
1 change: 1 addition & 0 deletions packages/api-client/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './wishlist';
export * from './user';
export * from './menu';
export * from './page';
export * from './paymentintent';

export type CategoryFilter = Record<string, unknown>;
export type ShippingMethod = Record<string, unknown>;
Expand Down
3 changes: 3 additions & 0 deletions packages/api-client/src/types/paymentintent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type PaymentIntent = {
clientSecret: string;
};
81 changes: 48 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,71 @@ 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,
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'
}
};
15 changes: 9 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,20 @@ 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;
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