Stripe: upgrade module and API, switch to Checkout (#12785)

* upgrade stripe module

* switch stripe api to latest version

* fix api version in tests

* start upgrading client and server

* client: switch to redirect

* implement checkout session creation for gems, start implementing webhooks

* stripe: start refactoring one time payments

* working gems and gift payments

* start adding support for subscriptions

* stripe: migrate subscriptions and fix cancelling sub

* allow upgrading group plans

* remove console.log statements

* group plans: upgrade from static page / create new one

* fix #11885, correct group plan modal title

* silence more stripe webhooks

* fix group plans redirects

* implement editing payment method

* start cleaning up code

* fix(stripe): update in-code docs, fix eslint issues

* subscriptions tests

* remove and skip old tests

* skip integration tests

* fix client build

* stripe webhooks: throw error if request fails

* subscriptions: correctly pass groupId

* remove console.log

* stripe: add unit tests for one time payments

* wip: stripe checkout tests

* stripe createCheckoutSession unit tests

* stripe createCheckoutSession unit tests

* stripe createCheckoutSession unit tests (editing card)

* fix existing webhooks tests

* add new webhooks tests

* add more webhooks tests

* fix lint

* stripe integration tests

* better error handling when retrieving customer from stripe

* client: remove unused strings and improve error handling

* payments: limit gift message length (server)

* payments: limit gift message length (client)

* fix redirects when payment is cancelled

* add back "subUpdateCard" string

* fix redirects when editing a sub card, use proper names for products, check subs when gifting
This commit is contained in:
Matteo Pagliazzi
2020-12-14 15:59:17 +01:00
parent 5daf96bbf5
commit 2d091fc667
53 changed files with 2457 additions and 1661 deletions
@@ -53,7 +53,7 @@
v-if="!group.purchased.plan.dateTerminated
&& group.purchased.plan.paymentMethod === 'Stripe'"
class="btn btn-primary"
@click="showStripeEdit({groupId: group.id})"
@click="redirectToStripeEdit({groupId: group.id})"
>
{{ $t('subUpdateCard') }}
</div>
@@ -202,7 +202,7 @@ export default {
this.paymentMethod = paymentMethod;
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
this.showStripe(paymentData);
this.redirectToStripe(paymentData);
} else if (this.paymentMethod === this.PAYMENTS.AMAZON) {
paymentData.type = 'subscription';
return paymentData;
@@ -155,7 +155,7 @@
</div>
<b-modal
id="group-plan-modal"
title="Select Payment"
:title="activePage === PAGES.CREATE_GROUP ? 'Create your Group' : 'Select Payment'"
size="md"
hide-footer="hide-footer"
>
@@ -524,7 +524,7 @@ export default {
}
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
this.showStripe(paymentData);
this.redirectToStripe(paymentData);
}
return null;
@@ -135,7 +135,7 @@
</div>
<payments-buttons
:disabled="!selectedGemsBlock"
:stripe-fn="() => showStripe({ gemsBlock: selectedGemsBlock })"
:stripe-fn="() => redirectToStripe({ gemsBlock: selectedGemsBlock })"
:paypal-fn="() => openPaypal({
url: paypalCheckoutLink, type: 'gems', gemsBlock: selectedGemsBlock
})"
@@ -108,7 +108,9 @@
class="form-control"
rows="3"
:placeholder="$t('sendGiftMessagePlaceholder')"
:maxlength="MAX_GIFT_MESSAGE_LENGTH"
></textarea>
<span>{{ gift.message.length || 0 }} / {{ MAX_GIFT_MESSAGE_LENGTH }}</span>
<!--include ../formatting-help-->
</div>
<div class="modal-footer">
@@ -123,7 +125,7 @@
<payments-buttons
v-else
:disabled="!gift.subscription.key && gift.gems.amount < 1"
:stripe-fn="() => showStripe({gift, uuid: userReceivingGems._id, receiverName})"
:stripe-fn="() => redirectToStripe({gift, uuid: userReceivingGems._id, receiverName})"
:paypal-fn="() => openPaypalGift({
gift: gift, giftedTo: userReceivingGems._id, receiverName,
})"
@@ -171,6 +173,7 @@ import planGemLimits from '@/../../common/script/libs/planGemLimits';
import paymentsMixin from '@/mixins/payments';
import notificationsMixin from '@/mixins/notifications';
import paymentsButtons from '@/components/payments/buttons/list';
import { MAX_GIFT_MESSAGE_LENGTH } from '@/../../common/script/constants';
// @TODO: EMAILS.TECH_ASSISTANCE_EMAIL, load from config
const TECH_ASSISTANCE_EMAIL = 'admin@habitica.com';
@@ -198,6 +201,7 @@ export default {
},
sendingInProgress: false,
userReceivingGems: null,
MAX_GIFT_MESSAGE_LENGTH: MAX_GIFT_MESSAGE_LENGTH.toString(),
};
},
computed: {
@@ -131,7 +131,7 @@
<button
class="btn btn-primary btn-update-card
d-flex justify-content-center align-items-center"
@click="showStripeEdit()"
@click="redirectToStripeEdit()"
>
<div
v-once
@@ -27,7 +27,10 @@
</b-form-group>
<payments-buttons
:disabled="!subscription.key"
:stripe-fn="() => showStripe({subscription:subscription.key, coupon:subscription.coupon})"
:stripe-fn="() => redirectToStripe({
subscription: subscription.key,
coupon: subscription.coupon,
})"
:paypal-fn="() => openPaypal({url: paypalPurchaseLink, type: 'subscription'})"
:amazon-data="{
type: 'subscription',
+1 -1
View File
@@ -25,6 +25,6 @@ export function setup () { // eslint-disable-line import/prefer-default-export
const stripeScript = document.createElement('script');
[firstScript] = document.getElementsByTagName('script');
stripeScript.async = true;
stripeScript.src = '//checkout.stripe.com/v2/checkout.js';
stripeScript.src = 'https://js.stripe.com/v3/';
firstScript.parentNode.insertBefore(stripeScript, firstScript);
}
+94 -111
View File
@@ -5,12 +5,12 @@ import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks
import { mapState } from '@/libs/store';
import encodeParams from '@/libs/encodeParams';
import notificationsMixin from '@/mixins/notifications';
import * as Analytics from '@/libs/analytics';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
const { STRIPE_PUB_KEY } = process.env;
const habiticaUrl = `${window.location.protocol}//${window.location.host}`;
// const habiticaUrl = `${window.location.protocol}//${window.location.host}`;
let stripeInstance = null;
export default {
mixins: [notificationsMixin],
@@ -100,7 +100,10 @@ export default {
// Listen for changes to local storage, indicating that the payment completed
window.addEventListener('storage', localStorageChangeHandled);
},
showStripe (data) {
async redirectToStripe (data) {
if (!stripeInstance) {
stripeInstance = window.Stripe(STRIPE_PUB_KEY);
}
if (!this.checkGemAmount(data)) return;
let sub = false;
@@ -113,12 +116,6 @@ export default {
sub = sub && subscriptionBlocks[sub];
let amount;
if (data.gemsBlock) amount = data.gemsBlock.price;
if (sub) amount = sub.price * 100;
if (data.gift && data.gift.type === 'gems') amount = (data.gift.gems.amount / 4) * 100;
if (data.group) amount = (sub.price + 3 * (data.group.memberCount - 1)) * 100;
let paymentType;
if (sub === false && !data.gift) paymentType = 'gems';
if (sub !== false && !data.gift) paymentType = 'subscription';
@@ -126,124 +123,110 @@ export default {
if (data.gift && data.gift.type === 'gems') paymentType = 'gift-gems';
if (data.gift && data.gift.type === 'subscription') paymentType = 'gift-subscription';
const label = (sub && paymentType !== 'gift-subscription')
? this.$t('subscribe')
: this.$t('checkout');
let url = '/stripe/checkout-session';
const postData = {};
window.StripeCheckout.open({
key: STRIPE_PUB_KEY,
address: false,
amount,
name: 'Habitica',
description: label,
// image: '/apple-touch-icon-144-precomposed.png',
panelLabel: label,
token: async res => {
let url = '/stripe/checkout?a=a'; // just so I can concat &x=x below
if (data.groupToCreate) {
url = '/api/v4/groups/create-plan';
postData.groupToCreate = data.groupToCreate;
postData.paymentType = 'Stripe';
}
if (data.groupToCreate) {
url = '/api/v4/groups/create-plan?a=a';
res.groupToCreate = data.groupToCreate;
res.paymentType = 'Stripe';
}
if (data.gemsBlock) postData.gemsBlock = data.gemsBlock.key;
if (data.gift) {
data.gift.uuid = data.uuid;
postData.gift = data.gift;
}
if (data.subscription) postData.sub = sub.key;
if (data.coupon) postData.coupon = data.coupon;
if (data.groupId) postData.groupId = data.groupId;
if (data.gemsBlock) url += `&gemsBlock=${data.gemsBlock.key}`;
if (data.gift) url += `&gift=${this.encodeGift(data.uuid, data.gift)}`;
if (data.subscription) url += `&sub=${sub.key}`;
if (data.coupon) url += `&coupon=${data.coupon}`;
if (data.groupId) url += `&groupId=${data.groupId}`;
const response = await axios.post(url, postData);
const response = await axios.post(url, res);
const appState = {
paymentMethod: 'stripe',
paymentCompleted: false,
paymentType,
};
if (paymentType === 'subscription') {
appState.subscriptionKey = sub.key;
} else if (paymentType === 'groupPlan') {
appState.subscriptionKey = sub.key;
// @TODO handle with normal notifications?
const responseStatus = response.status;
if (responseStatus >= 400) {
window.alert(`Error: ${response.message}`); // eslint-disable-line no-alert
return;
}
// Handle new user signup
if (!this.$store.state.isUserLoggedIn) {
appState.newSignup = true;
}
const appState = {
paymentMethod: 'stripe',
paymentCompleted: true,
paymentType,
};
if (paymentType === 'subscription') {
appState.subscriptionKey = sub.key;
} else if (paymentType === 'groupPlan') {
appState.subscriptionKey = sub.key;
if (data.groupToCreate) {
appState.newGroup = true;
appState.group = pick(response.data.data.group, ['_id', 'memberCount', 'name', 'type']);
} else {
appState.newGroup = false;
appState.group = pick(data.group, ['_id', 'memberCount', 'name', 'type']);
}
} else if (paymentType.indexOf('gift-') === 0) {
appState.gift = data.gift;
appState.giftReceiver = data.receiverName;
} else if (paymentType === 'gems') {
appState.gemsBlock = data.gemsBlock;
}
if (data.groupToCreate) {
appState.newGroup = true;
appState.group = pick(data.groupToCreate, ['_id', 'memberCount', 'name']);
} else {
appState.newGroup = false;
appState.group = pick(data.group, ['_id', 'memberCount', 'name']);
}
} else if (paymentType.indexOf('gift-') === 0) {
appState.gift = data.gift;
appState.giftReceiver = data.receiverName;
} else if (paymentType === 'gems') {
appState.gemsBlock = data.gemsBlock;
}
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
const newGroup = response.data.data;
if (newGroup && newGroup._id) {
// @TODO this does not do anything as we reload just below
// @TODO: Just append? or $emit?
// Handle new user signup
if (!this.$store.state.isUserLoggedIn) {
Analytics.track({
hitType: 'event',
eventCategory: 'group-plans-static',
eventAction: 'view',
eventLabel: 'paid-with-stripe',
});
window.location.assign(`${habiticaUrl}/group-plans/${newGroup._id}/task-information?showGroupOverview=true`);
return;
}
this.user.guilds.push(newGroup._id);
window.location.assign(`${habiticaUrl}/group-plans/${newGroup._id}/task-information`);
return;
}
if (data.groupId) {
window.location.assign(`${habiticaUrl}/group-plans/${data.groupId}/task-information`);
return;
}
window.location.reload(true);
},
});
try {
const checkoutSessionResult = await stripeInstance.redirectToCheckout({
sessionId: response.data.data.sessionId,
});
if (checkoutSessionResult.error) {
console.error(checkoutSessionResult.error); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
throw checkoutSessionResult.error;
}
} catch (err) {
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${err.message}`);
throw err;
}
},
showStripeEdit (config) {
async redirectToStripeEdit (config) {
if (!stripeInstance) {
stripeInstance = window.Stripe(STRIPE_PUB_KEY);
}
let groupId;
if (config && config.groupId) {
groupId = config.groupId;
}
window.StripeCheckout.open({
key: STRIPE_PUB_KEY,
address: false,
name: this.$t('subUpdateTitle'),
description: this.$t('subUpdateDescription'),
panelLabel: this.$t('subUpdateCard'),
token: async data => {
data.groupId = groupId;
const url = '/stripe/subscribe/edit';
const response = await axios.post(url, data);
const appState = {
paymentMethod: 'stripe',
isStripeEdit: true,
paymentCompleted: false,
paymentType: groupId ? 'groupPlan' : 'subscription',
groupId,
};
// Success
window.location.reload(true);
// error
window.alert(response.message); // eslint-disable-line no-alert
},
const response = await axios.post('/stripe/subscribe/edit', {
groupId,
});
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
try {
const checkoutSessionResult = await stripeInstance.redirectToCheckout({
sessionId: response.data.data.sessionId,
});
if (checkoutSessionResult.error) {
console.error(checkoutSessionResult.error); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
throw checkoutSessionResult.error;
}
} catch (err) {
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
alert(`Error while redirecting to Stripe: ${err.message}`);
throw err;
}
},
checkGemAmount (data) {
const isGem = data && data.gift && data.gift.type === 'gems';
+99 -2
View File
@@ -1,4 +1,5 @@
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
export default function (to, from, next) {
const { redirect } = to.params;
@@ -13,9 +14,105 @@ export default function (to, from, next) {
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(newAppState));
}
window.close();
break;
return null;
}
case 'stripe-success-checkout': {
const appState = getLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
if (appState) {
const newAppState = JSON.parse(appState);
newAppState.paymentCompleted = true;
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(newAppState));
if (newAppState.isStripeEdit) {
if (newAppState.paymentType === 'subscription') {
return next({ name: 'subscription' });
}
if (newAppState.paymentType === 'groupPlan') {
return next({
name: 'groupPlanBilling',
params: { groupId: newAppState.groupId },
});
}
}
const newGroup = newAppState.group;
if (newGroup && newGroup._id) {
// Handle new user signup
if (newAppState.newSignup === true) {
Analytics.track({
hitType: 'event',
eventCategory: 'group-plans-static',
eventAction: 'view',
eventLabel: 'paid-with-stripe',
});
return next({
name: 'groupPlanDetailTaskInformation',
params: { groupId: newGroup._id },
query: { showGroupOverview: 'true' },
});
}
return next({
name: 'groupPlanDetailTaskInformation',
params: { groupId: newGroup._id },
});
}
}
return next({ name: 'tasks' });
}
case 'stripe-error-checkout': {
const appState = getLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
if (appState) {
const newAppState = JSON.parse(appState);
const {
paymentType,
gift,
newGroup,
group,
isStripeEdit,
groupId,
} = newAppState;
if (paymentType === 'subscription') {
return next({ name: 'subscription' });
}
if (paymentType === 'groupPlan') {
if (isStripeEdit) {
return next({
name: 'groupPlanBilling',
params: { groupId },
});
}
if (newGroup) {
return next({ name: 'groupPlan' });
}
if (group.type === 'party') {
return next({
name: 'party',
});
}
return next({
name: 'guild',
params: { groupId: group._id },
});
}
if (paymentType.indexOf('gift-') === 0) {
return next({ name: 'userProfile', params: { userId: gift.uuid } });
}
if (paymentType === 'gems') {
return next({ name: 'tasks', query: { openGemsModal: true } });
}
}
return next({ name: 'tasks' });
}
default:
next({ name: 'notFound' });
return next({ name: 'notFound' });
}
}
+5
View File
@@ -425,6 +425,11 @@ router.beforeEach((to, from, next) => {
return null;
}
if (to.name === 'tasks' && to.query.openGemsModal === 'true') {
setTimeout(() => router.app.$emit('bv::show::modal', 'buy-gems'), 500);
return next({ name: 'tasks' });
}
if ((to.name === 'stats' || to.name === 'achievements' || to.name === 'profile') && from.name !== null) {
router.app.$emit('habitica:show-profile', {
startingPage: to.name,