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:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"sendGiftMessagePlaceholder": "Personal message (optional)",
|
||||
"sendGiftSubscription": "<%= months %> Month(s): $<%= price %> USD",
|
||||
"gemGiftsAreOptional": "Please note that Habitica will never require you to gift gems to other players. Begging people for gems is a <strong>violation of the Community Guidelines</strong>, and all such instances should be reported to <%= hrefTechAssistanceEmail %>.",
|
||||
"giftMessageTooLong": "The maximum length for gift messages is <%= maxGiftMessageLength %>.",
|
||||
"battleWithFriends": "Battle Monsters With Friends",
|
||||
"startAParty": "Start a Party",
|
||||
"partyUpName": "Party Up",
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"namedHatchingPotion": "<%= type %> Hatching Potion",
|
||||
"buyGems": "Buy Gems",
|
||||
"purchaseGems": "Purchase Gems",
|
||||
"nGems": "<%= nGems %> Gems",
|
||||
"nGemsGift": "<%= nGems %> Gems (Gift)",
|
||||
"nMonthsSubscriptionGift": "<%= nMonths %> Month(s) Subscription (Gift)",
|
||||
"items": "Items",
|
||||
"AZ": "A-Z",
|
||||
"sort": "Sort",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"giftSubscriptionText4": "Thanks for supporting Habitica!",
|
||||
"organization": "Organization",
|
||||
"groupPlans": "Group Plans",
|
||||
"subscribe": "Subscribe",
|
||||
"nowSubscribed": "You are now subscribed to Habitica!",
|
||||
"cancelSub": "Cancel Subscription",
|
||||
"cancelSubInfoGoogle": "Please go to the \"Account\" > \"Subscriptions\" section of the Google Play Store app to cancel your subscription or to see your subscription's termination date if you have already cancelled it. This screen is not able to show you whether your subscription has been cancelled.",
|
||||
@@ -125,7 +124,6 @@
|
||||
"mysterySetwondercon": "Wondercon",
|
||||
"subUpdateCard": "Update Credit Card",
|
||||
"subUpdateTitle": "Update",
|
||||
"subUpdateDescription": "Update the card to be charged.",
|
||||
"notEnoughHourglasses": "You don't have enough Mystic Hourglasses.",
|
||||
"backgroundAlreadyOwned": "Background already owned.",
|
||||
"petsAlreadyOwned": "Pet already owned.",
|
||||
|
||||
@@ -11,6 +11,8 @@ export const MAX_SUMMARY_SIZE_FOR_CHALLENGES = 250;
|
||||
export const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3;
|
||||
export const MAX_MESSAGE_LENGTH = 3000;
|
||||
|
||||
export const MAX_GIFT_MESSAGE_LENGTH = 200;
|
||||
|
||||
export const CHAT_FLAG_LIMIT_FOR_HIDING = 2; // hide posts that have this many flags
|
||||
export const CHAT_FLAG_FROM_MOD = 5; // a flag from a moderator counts as this many flags
|
||||
// a shadow-muted user's post starts with this many flags
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SUPPORTED_SOCIAL_NETWORKS,
|
||||
TAVERN_ID,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
MAX_GIFT_MESSAGE_LENGTH,
|
||||
} from './constants';
|
||||
import content from './content/index';
|
||||
import * as count from './count';
|
||||
@@ -111,6 +112,7 @@ api.constants = {
|
||||
CHAT_FLAG_FROM_SHADOW_MUTE,
|
||||
MINIMUM_PASSWORD_LENGTH,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
MAX_GIFT_MESSAGE_LENGTH,
|
||||
};
|
||||
// TODO Move these under api.constants
|
||||
api.maxLevel = MAX_LEVEL;
|
||||
|
||||
@@ -198,8 +198,7 @@ api.createGroupPlan = {
|
||||
const results = await Promise.all([user.save(), group.save()]);
|
||||
const savedGroup = results[1];
|
||||
|
||||
// Analytics
|
||||
const analyticsObject = {
|
||||
res.analytics.track('join group', {
|
||||
uuid: user._id,
|
||||
hitType: 'event',
|
||||
category: 'behavior',
|
||||
@@ -207,27 +206,31 @@ api.createGroupPlan = {
|
||||
groupType: savedGroup.type,
|
||||
privacy: savedGroup.privacy,
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
// do not remove chat flags data as we've just created the group
|
||||
const groupResponse = savedGroup.toJSON();
|
||||
// the leader is the authenticated user
|
||||
groupResponse.leader = {
|
||||
_id: user._id,
|
||||
profile: { name: user.profile.name },
|
||||
};
|
||||
res.analytics.track('join group', analyticsObject);
|
||||
|
||||
if (req.body.paymentType === 'Stripe') {
|
||||
const token = req.body.id;
|
||||
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
const sub = req.query.sub ? common.content.subscriptionBlocks[req.query.sub] : false;
|
||||
const groupId = savedGroup._id;
|
||||
const { email } = req.body;
|
||||
const { headers } = req;
|
||||
const { coupon } = req.query;
|
||||
const {
|
||||
gift, sub: subKey, gemsBlock, coupon,
|
||||
} = req.body;
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
const sub = subKey ? common.content.subscriptionBlocks[subKey] : false;
|
||||
const groupId = savedGroup._id;
|
||||
|
||||
const session = await stripePayments.createCheckoutSession({
|
||||
user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
||||
});
|
||||
|
||||
res.respond(200, {
|
||||
sessionId: session.id,
|
||||
group: groupResponse,
|
||||
});
|
||||
} else if (req.body.paymentType === 'Amazon') {
|
||||
const { billingAgreementId } = req.body;
|
||||
@@ -246,19 +249,9 @@ api.createGroupPlan = {
|
||||
groupId,
|
||||
headers,
|
||||
});
|
||||
|
||||
res.respond(201, groupResponse);
|
||||
}
|
||||
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]);
|
||||
// doc.populate doesn't return a promise
|
||||
const response = savedGroup.toJSON();
|
||||
// the leader is the authenticated user
|
||||
response.leader = {
|
||||
_id: user._id,
|
||||
profile: { name: user.profile.name },
|
||||
};
|
||||
|
||||
res.respond(201, response); // do not remove chat flags data as we've just created the group
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,37 +8,37 @@ const api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/checkout Stripe checkout
|
||||
* @api {post} /stripe/checkout-session Create a Stripe Checkout Session
|
||||
* @apiName StripeCheckout
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {String} id Body parameter - The token
|
||||
* @apiParam {String} email Body parameter - the customer email
|
||||
* @apiParam {String} gift Query parameter - stringified json object, gift
|
||||
* @apiParam {String} sub Query parameter - subscription, possible values are:
|
||||
* basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
|
||||
* @apiParam {String} coupon Query parameter - coupon for the matching subscription,
|
||||
* required only for certain subscriptions
|
||||
* @apiParam (Body) {String} [gemsBlock] If purchasing a gem block, its key
|
||||
* @apiParam (Body) {Object} [gift] The gift object
|
||||
* @apiParam (Body) {String} [sub] If purchasing a subscription, its key
|
||||
* @apiParam (Body) {UUID} [groupId] If purchasing a group plan, the group id
|
||||
* @apiParam (Body) {String} [coupon] Subscription Coupon
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
* @apiSuccess {String} data.sessionId The created checkout session id
|
||||
* */
|
||||
api.checkout = {
|
||||
api.createCheckoutSession = {
|
||||
method: 'POST',
|
||||
url: '/stripe/checkout',
|
||||
url: '/stripe/checkout-session',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
// @TODO: These quer params need to be changed to body
|
||||
const token = req.body.id;
|
||||
const { user } = res.locals;
|
||||
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
const sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
||||
const { groupId, coupon, gemsBlock } = req.query;
|
||||
const {
|
||||
gift, sub: subKey, gemsBlock, coupon, groupId,
|
||||
} = req.body;
|
||||
|
||||
await stripePayments.checkout({
|
||||
token, user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
||||
const sub = subKey ? shared.content.subscriptionBlocks[subKey] : false;
|
||||
|
||||
const session = await stripePayments.createCheckoutSession({
|
||||
user, gemsBlock, gift, sub, groupId, coupon,
|
||||
});
|
||||
|
||||
res.respond(200, {});
|
||||
res.respond(200, {
|
||||
sessionId: session.id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,22 +48,23 @@ api.checkout = {
|
||||
* @apiName StripeSubscribeEdit
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {String} id Body parameter - The token
|
||||
* @apiParam (Body) {UUID} [groupId] If editing a group plan, the group id
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
* @apiSuccess {String} data.sessionId The created checkout session id
|
||||
* */
|
||||
api.subscribeEdit = {
|
||||
method: 'POST',
|
||||
url: '/stripe/subscribe/edit',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
const token = req.body.id;
|
||||
const { groupId } = req.body;
|
||||
const { user } = res.locals;
|
||||
|
||||
await stripePayments.editSubscription({ token, groupId, user });
|
||||
const session = await stripePayments.createEditCardCheckoutSession({ groupId, user });
|
||||
|
||||
res.respond(200, {});
|
||||
res.respond(200, {
|
||||
sessionId: session.id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,6 +73,9 @@ api.subscribeEdit = {
|
||||
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
|
||||
* @apiName StripeSubscribeCancel
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam (Body) {UUID} [groupId] If editing a group plan, the group id
|
||||
*
|
||||
* */
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
@@ -91,11 +95,20 @@ api.subscribeCancel = {
|
||||
},
|
||||
};
|
||||
|
||||
// NOTE: due to Stripe requirements on validating webhooks, the body is not json parsed
|
||||
// for this route, see website/server/middlewares/index.js
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/webhooks Stripe Webhooks handler
|
||||
* @apiName StripeHandleWebhooks
|
||||
* @apiGroup Payments
|
||||
* */
|
||||
api.handleWebhooks = {
|
||||
method: 'POST',
|
||||
url: '/stripe/webhooks',
|
||||
async handler (req, res) {
|
||||
await stripePayments.handleWebhooks({ requestBody: req.body });
|
||||
await stripePayments.handleWebhooks({ body: req.body, headers: req.headers });
|
||||
|
||||
return res.respond(200, {});
|
||||
},
|
||||
|
||||
@@ -151,6 +151,7 @@ function removeTerminatedSubscription (user) {
|
||||
_.merge(plan, {
|
||||
planId: null,
|
||||
customerId: null,
|
||||
subscriptionId: null,
|
||||
paymentMethod: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { // eslint-disable-line import/no-cycle
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../models/group';
|
||||
import { model as Coupon } from '../../models/coupon';
|
||||
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock, validateGiftMessage } from './gems'; // eslint-disable-line import/no-cycle
|
||||
|
||||
// TODO better handling of errors
|
||||
|
||||
@@ -117,6 +117,7 @@ api.checkout = async function checkout (options = {}) {
|
||||
|
||||
if (gift) {
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
validateGiftMessage(gift, user);
|
||||
|
||||
if (gift.type === this.constants.GIFT_TYPE_GEMS) {
|
||||
if (gift.gems.amount <= 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import moment from 'moment';
|
||||
import shared from '../../../common';
|
||||
import iap from '../inAppPurchases';
|
||||
import payments from './payments';
|
||||
import { getGemsBlock } from './gems';
|
||||
import { getGemsBlock, validateGiftMessage } from './gems';
|
||||
import {
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
@@ -28,6 +28,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
|
||||
} = options;
|
||||
|
||||
if (gift) {
|
||||
validateGiftMessage(gift, user);
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
}
|
||||
|
||||
@@ -232,6 +233,7 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
validateGiftMessage(gift, user);
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
gift.subscription = sub;
|
||||
data.gift = gift;
|
||||
|
||||
@@ -62,6 +62,17 @@ async function buyGemGift (data) {
|
||||
await data.gift.member.save();
|
||||
}
|
||||
|
||||
const { MAX_GIFT_MESSAGE_LENGTH } = shared.constants;
|
||||
export function validateGiftMessage (gift, user) {
|
||||
if (gift.message && gift.message.length > MAX_GIFT_MESSAGE_LENGTH) {
|
||||
throw new BadRequest(shared.i18n.t(
|
||||
'giftMessageTooLong',
|
||||
{ maxGiftMessageLength: MAX_GIFT_MESSAGE_LENGTH },
|
||||
user.preferences.language,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
export function getGemsBlock (gemsBlock) {
|
||||
const block = shared.content.gems[gemsBlock];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../errors';
|
||||
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
|
||||
import { model as User } from '../../models/user';
|
||||
import { getGemsBlock } from './gems';
|
||||
import { getGemsBlock, validateGiftMessage } from './gems';
|
||||
|
||||
const api = {};
|
||||
|
||||
@@ -28,6 +28,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
|
||||
|
||||
if (gift) {
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
validateGiftMessage(gift, user);
|
||||
}
|
||||
const receiver = gift ? gift.member : user;
|
||||
const receiverCanGetGems = await receiver.canGetGems();
|
||||
@@ -214,6 +215,7 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
validateGiftMessage(gift, user);
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
gift.subscription = sub;
|
||||
data.gift = gift;
|
||||
|
||||
@@ -8,7 +8,7 @@ import paypal from 'paypal-rest-sdk';
|
||||
import cc from 'coupon-code';
|
||||
import shared from '../../../common';
|
||||
import payments from './payments'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock, validateGiftMessage } from './gems'; // eslint-disable-line import/no-cycle
|
||||
import { model as Coupon } from '../../models/coupon';
|
||||
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
@@ -87,6 +87,8 @@ api.checkout = async function checkout (options = {}) {
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
gift.member = member;
|
||||
|
||||
validateGiftMessage(gift, user);
|
||||
|
||||
if (gift.type === 'gems') {
|
||||
if (gift.gems.amount <= 0) {
|
||||
throw new BadRequest(i18n.t('badAmountOfGemsToPurchase'));
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import moment from 'moment';
|
||||
|
||||
import logger from '../logger';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from '../errors';
|
||||
import payments from './payments'; // eslint-disable-line import/no-cycle
|
||||
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../models/group';
|
||||
import shared from '../../../common';
|
||||
import stripeConstants from './stripe/constants';
|
||||
import { checkout } from './stripe/checkout'; // eslint-disable-line import/no-cycle
|
||||
import { getStripeApi, setStripeApi } from './stripe/api';
|
||||
|
||||
const { i18n } = shared;
|
||||
|
||||
const api = {};
|
||||
|
||||
api.constants = { ...stripeConstants };
|
||||
|
||||
api.setStripeApi = setStripeApi;
|
||||
|
||||
/**
|
||||
* Allows for purchasing a user subscription, group subscription or gems with Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.token The stripe token generated on the front end
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.gift The gift details if any
|
||||
* @param options.sub The subscription data to purchase
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.email The email enter by the user on the Stripe form
|
||||
* @param options.headers The request headers to store on analytics
|
||||
* @return undefined
|
||||
*/
|
||||
api.checkout = checkout;
|
||||
|
||||
/**
|
||||
* Edits a subscription created by Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.token The stripe token generated on the front end
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.editSubscription = async function editSubscription (options, stripeInc) {
|
||||
const { token, groupId, user } = options;
|
||||
let customerId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (groupId) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFound(i18n.t('groupNotFound'));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
|
||||
}
|
||||
customerId = group.purchased.plan.customerId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription'));
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
// @TODO: Handle Stripe Error response
|
||||
const subscriptions = await stripeApi.subscriptions.list({ customer: customerId });
|
||||
const subscriptionId = subscriptions.data[0].id;
|
||||
await stripeApi.subscriptions.update(subscriptionId, { card: token });
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels a subscription created by Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.cancellationReason A text string to control sending an email
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.cancelSubscription = async function cancelSubscription (options, stripeInc) {
|
||||
const { groupId, user, cancellationReason } = options;
|
||||
let customerId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (groupId) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFound(i18n.t('groupNotFound'));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
|
||||
}
|
||||
customerId = group.purchased.plan.customerId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription'));
|
||||
|
||||
// @TODO: Handle error response
|
||||
const customer = await stripeApi.customers.retrieve(customerId).catch(err => err);
|
||||
let nextBill = moment().add(30, 'days').unix() * 1000;
|
||||
|
||||
if (customer && (customer.subscription || customer.subscriptions)) {
|
||||
let { subscription } = customer;
|
||||
if (!subscription && customer.subscriptions) {
|
||||
[subscription] = customer.subscriptions.data;
|
||||
}
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
if (subscription && subscription.current_period_end) {
|
||||
nextBill = subscription.current_period_end * 1000; // timestamp in seconds
|
||||
}
|
||||
}
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
nextBill,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
cancellationReason,
|
||||
});
|
||||
};
|
||||
|
||||
api.chargeForAdditionalGroupMember = async function chargeForAdditionalGroupMember (group) {
|
||||
const stripeApi = getStripeApi();
|
||||
const plan = shared.content.subscriptionBlocks.group_monthly;
|
||||
|
||||
await stripeApi.subscriptions.update(
|
||||
group.purchased.plan.subscriptionId,
|
||||
{
|
||||
plan: plan.key,
|
||||
quantity: group.memberCount + plan.quantity - 1,
|
||||
},
|
||||
);
|
||||
|
||||
group.purchased.plan.quantity = group.memberCount + plan.quantity - 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle webhooks from stripes
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.handleWebhooks = async function handleWebhooks (options, stripeInc) {
|
||||
const { requestBody } = options;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
// Verify the event by fetching it from Stripe
|
||||
const event = await stripeApi.events.retrieve(requestBody.id);
|
||||
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.deleted': {
|
||||
// event.request !== null means that the user itself cancelled the subscrioption,
|
||||
// the cancellation on our side has been already handled
|
||||
if (event.request !== null) break;
|
||||
|
||||
const subscription = event.data.object;
|
||||
const customerId = subscription.customer;
|
||||
const isGroupSub = shared.content.subscriptionBlocks[subscription.plan.id].target === 'group';
|
||||
|
||||
let user;
|
||||
let groupId;
|
||||
|
||||
if (isGroupSub) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).select(groupFields).exec();
|
||||
|
||||
if (!group) throw new NotFound(i18n.t('groupNotFound'));
|
||||
groupId = group._id;
|
||||
|
||||
user = await User.findById(group.leader).exec();
|
||||
} else {
|
||||
user = await User.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
if (!user) throw new NotFound(i18n.t('userNotFound'));
|
||||
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
// Give three extra days to allow the user to resubscribe without losing benefits
|
||||
nextBill: moment().add({ days: 3 }).toDate(),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
logger.error(new Error(`Missing handler for Stripe webhook ${event.type}`), { event });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -1,7 +1,9 @@
|
||||
import stripeModule from 'stripe';
|
||||
import nconf from 'nconf';
|
||||
|
||||
let stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
|
||||
let stripe = stripeModule(nconf.get('STRIPE_API_KEY'), {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
|
||||
function setStripeApi (stripeInc) {
|
||||
stripe = stripeInc;
|
||||
|
||||
@@ -1,161 +1,187 @@
|
||||
import cc from 'coupon-code';
|
||||
import nconf from 'nconf';
|
||||
|
||||
import { getStripeApi } from './api';
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import {
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../../models/group';
|
||||
import shared from '../../../../common';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../errors';
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock } from '../gems'; // eslint-disable-line import/no-cycle
|
||||
import stripeConstants from './constants';
|
||||
import { getOneTimePaymentInfo } from './oneTimePayments'; // eslint-disable-line import/no-cycle
|
||||
import { checkSubData } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||
import { validateGiftMessage } from '../gems'; // eslint-disable-line import/no-cycle
|
||||
|
||||
function getGiftAmount (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
return `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
||||
}
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
if (gift.gems.amount <= 0) {
|
||||
throw new BadRequest(shared.i18n.t('badAmountOfGemsToPurchase'));
|
||||
}
|
||||
|
||||
return `${(gift.gems.amount / 4) * 100}`;
|
||||
}
|
||||
|
||||
async function buyGems (gemsBlock, gift, user, token, stripeApi) {
|
||||
let amount;
|
||||
|
||||
if (gift) {
|
||||
amount = getGiftAmount(gift);
|
||||
} else {
|
||||
amount = gemsBlock.price;
|
||||
}
|
||||
|
||||
if (!gift || gift.type === 'gems') {
|
||||
const receiver = gift ? gift.member : user;
|
||||
const receiverCanGetGems = await receiver.canGetGems();
|
||||
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
|
||||
}
|
||||
|
||||
const response = await stripeApi.charges.create({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
card: token,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function buySubscription (sub, coupon, email, user, token, groupId, stripeApi) {
|
||||
if (sub.discount) {
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
|
||||
coupon = await Coupon // eslint-disable-line no-param-reassign
|
||||
.findOne({ _id: cc.validate(coupon), event: sub.key }).exec();
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
const customerObject = {
|
||||
email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
};
|
||||
|
||||
if (groupId) {
|
||||
customerObject.quantity = sub.quantity;
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
const membersCount = await group.getMemberCount();
|
||||
customerObject.quantity = membersCount + sub.quantity - 1;
|
||||
}
|
||||
|
||||
const response = await stripeApi.customers.create(customerObject);
|
||||
|
||||
let subscriptionId;
|
||||
if (groupId) subscriptionId = response.subscriptions.data[0].id;
|
||||
|
||||
return { subResponse: response, subId: subscriptionId };
|
||||
}
|
||||
|
||||
async function applyGemPayment (user, response, gemsBlock, gift) {
|
||||
let method = 'buyGems';
|
||||
const data = {
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: stripeConstants.PAYMENT_METHOD,
|
||||
gemsBlock,
|
||||
gift,
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
}
|
||||
|
||||
export async function checkout (options, stripeInc) {
|
||||
export async function createCheckoutSession (options, stripeInc) {
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
gemsBlock,
|
||||
gemsBlock: gemsBlockKey,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
} = options;
|
||||
let response;
|
||||
let subscriptionId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
let type = 'gems';
|
||||
if (gift) {
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
gift.member = member;
|
||||
type = gift.type === 'gems' ? 'gift-gems' : 'gift-sub';
|
||||
validateGiftMessage(gift, user);
|
||||
} else if (sub) {
|
||||
type = 'subscription';
|
||||
}
|
||||
|
||||
let block;
|
||||
if (!sub && !gift) {
|
||||
block = getGemsBlock(gemsBlock);
|
||||
}
|
||||
const metadata = {
|
||||
type,
|
||||
userId: user._id,
|
||||
gift: gift ? JSON.stringify(gift) : undefined,
|
||||
sub: sub ? JSON.stringify(sub) : undefined,
|
||||
};
|
||||
|
||||
if (sub) {
|
||||
const { subId, subResponse } = await buySubscription(
|
||||
sub, coupon, email, user, token, groupId, stripeApi,
|
||||
);
|
||||
subscriptionId = subId;
|
||||
response = subResponse;
|
||||
let lineItems;
|
||||
|
||||
if (type === 'subscription') {
|
||||
let quantity = 1;
|
||||
|
||||
if (groupId) {
|
||||
quantity = sub.quantity;
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFound(shared.i18n.t('groupNotFound', user.preferences.language));
|
||||
}
|
||||
const membersCount = await group.getMemberCount();
|
||||
quantity = membersCount + sub.quantity - 1;
|
||||
metadata.groupId = groupId;
|
||||
}
|
||||
|
||||
await checkSubData(sub, Boolean(groupId), coupon);
|
||||
|
||||
lineItems = [{
|
||||
price: sub.key,
|
||||
quantity,
|
||||
}];
|
||||
} else {
|
||||
response = await buyGems(block, gift, user, token, stripeApi);
|
||||
const {
|
||||
amount,
|
||||
gemsBlock,
|
||||
subscription,
|
||||
} = await getOneTimePaymentInfo(gemsBlockKey, gift, user);
|
||||
|
||||
metadata.gemsBlock = gemsBlock ? gemsBlock.key : undefined;
|
||||
|
||||
let productName;
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
productName = shared.i18n.t('nMonthsSubscriptionGift', { nMonths: subscription.months }, user.preferences.language);
|
||||
} else {
|
||||
productName = shared.i18n.t('nGemsGift', { nGems: gift.gems.amount }, user.preferences.language);
|
||||
}
|
||||
} else {
|
||||
productName = shared.i18n.t('nGems', { nGems: gemsBlock.gems }, user.preferences.language);
|
||||
}
|
||||
|
||||
lineItems = [{
|
||||
price_data: {
|
||||
product_data: {
|
||||
name: productName,
|
||||
},
|
||||
unit_amount: amount,
|
||||
currency: 'usd',
|
||||
},
|
||||
quantity: 1,
|
||||
}];
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
sub,
|
||||
headers,
|
||||
groupId,
|
||||
subscriptionId,
|
||||
});
|
||||
} else {
|
||||
await applyGemPayment(user, response, block, gift);
|
||||
}
|
||||
const session = await stripeApi.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
metadata,
|
||||
line_items: lineItems,
|
||||
mode: type === 'subscription' ? 'subscription' : 'payment',
|
||||
success_url: `${BASE_URL}/redirect/stripe-success-checkout`,
|
||||
cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createEditCardCheckoutSession (options, stripeInc) {
|
||||
const {
|
||||
user,
|
||||
groupId,
|
||||
} = options;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
const type = groupId ? 'edit-card-group' : 'edit-card-user';
|
||||
|
||||
const metadata = {
|
||||
type,
|
||||
userId: user._id,
|
||||
};
|
||||
|
||||
let customerId;
|
||||
let subscriptionId;
|
||||
|
||||
if (type === 'edit-card-group') {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFound(shared.i18n.t('groupNotFound', user.preferences.language));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription', user.preferences.language));
|
||||
}
|
||||
metadata.groupId = groupId;
|
||||
customerId = group.purchased.plan.customerId;
|
||||
subscriptionId = group.purchased.plan.subscriptionId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
subscriptionId = user.purchased.plan.subscriptionId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(shared.i18n.t('missingSubscription', user.preferences.language));
|
||||
|
||||
if (!subscriptionId) {
|
||||
const subscriptions = await stripeApi.subscriptions.list({ customer: customerId });
|
||||
subscriptionId = subscriptions.data[0] && subscriptions.data[0].id;
|
||||
}
|
||||
|
||||
if (!subscriptionId) throw new NotAuthorized(shared.i18n.t('missingSubscription', user.preferences.language));
|
||||
|
||||
const session = await stripeApi.checkout.sessions.create({
|
||||
mode: 'setup',
|
||||
payment_method_types: ['card'],
|
||||
metadata,
|
||||
customer: customerId,
|
||||
setup_intent_data: {
|
||||
metadata: {
|
||||
customer_id: customerId,
|
||||
subscription_id: subscriptionId,
|
||||
},
|
||||
},
|
||||
success_url: `${BASE_URL}/redirect/stripe-success-checkout`,
|
||||
cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import stripeConstants from './constants';
|
||||
import { handleWebhooks } from './webhooks'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
createCheckoutSession,
|
||||
createEditCardCheckoutSession,
|
||||
} from './checkout';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
chargeForAdditionalGroupMember,
|
||||
cancelSubscription,
|
||||
} from './subscriptions';
|
||||
import { setStripeApi } from './api';
|
||||
|
||||
const api = {};
|
||||
|
||||
api.constants = { ...stripeConstants };
|
||||
|
||||
api.setStripeApi = setStripeApi;
|
||||
|
||||
/**
|
||||
* Allows for purchasing a user subscription, group subscription or gems with Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.gift The gift details if any
|
||||
* @param options.gift The gem block object, if any
|
||||
* @param options.sub The subscription data to purchase
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.coupon The coupon code, if any
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.createCheckoutSession = createCheckoutSession;
|
||||
|
||||
/**
|
||||
* Create a Stripe checkout session to edit a subscription payment method
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is making the request
|
||||
* @param options.groupId If editing a group plan, its id
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.createEditCardCheckoutSession = createEditCardCheckoutSession;
|
||||
|
||||
/**
|
||||
* Cancels a subscription created by Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.cancellationReason A text string to control sending an email
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.cancelSubscription = cancelSubscription;
|
||||
|
||||
/**
|
||||
* Update the quantity for a group plan subscription on Stripe
|
||||
*
|
||||
* @param grouo The affected group object
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.chargeForAdditionalGroupMember = chargeForAdditionalGroupMember;
|
||||
|
||||
/**
|
||||
* Handle webhooks from stripes
|
||||
*
|
||||
* @param options
|
||||
* @param options.body The raw request body
|
||||
* @param options.groupId The request's headers
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.handleWebhooks = handleWebhooks;
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,97 @@
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
import stripeConstants from './constants';
|
||||
import shared from '../../../../common';
|
||||
import { getGemsBlock } from '../gems'; // eslint-disable-line import/no-cycle
|
||||
import { checkSubData } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
|
||||
function getGiftAmount (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
return `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
||||
}
|
||||
|
||||
if (gift.gems.amount <= 0) {
|
||||
throw new BadRequest(shared.i18n.t('badAmountOfGemsToPurchase'));
|
||||
}
|
||||
|
||||
return `${(gift.gems.amount / 4) * 100}`;
|
||||
}
|
||||
|
||||
export async function getOneTimePaymentInfo (gemsBlockKey, gift, user) {
|
||||
let receiver = user;
|
||||
|
||||
if (gift) {
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
if (!member) {
|
||||
throw new NotFound(shared.i18n.t(
|
||||
'userWithIDNotFound', { userId: gift.uuid }, user.preferences.language,
|
||||
));
|
||||
}
|
||||
receiver = member;
|
||||
}
|
||||
|
||||
let amount;
|
||||
let gemsBlock = null;
|
||||
let subscription = null;
|
||||
|
||||
if (gift) {
|
||||
amount = getGiftAmount(gift);
|
||||
|
||||
if (gift.type === 'subscription') {
|
||||
subscription = shared.content.subscriptionBlocks[gift.subscription.key];
|
||||
await checkSubData(subscription, false, null);
|
||||
}
|
||||
} else {
|
||||
gemsBlock = getGemsBlock(gemsBlockKey);
|
||||
amount = gemsBlock.price;
|
||||
}
|
||||
|
||||
if (!gift || gift.type === 'gems') {
|
||||
const receiverCanGetGems = await receiver.canGetGems();
|
||||
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
|
||||
}
|
||||
|
||||
return {
|
||||
amount,
|
||||
gemsBlock,
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyGemPayment (session) {
|
||||
const { metadata, customer: customerId } = session;
|
||||
const { gemsBlock: gemsBlockKey, gift: giftStringified, userId } = metadata;
|
||||
|
||||
const gemsBlock = gemsBlockKey ? getGemsBlock(gemsBlockKey) : undefined;
|
||||
const gift = giftStringified ? JSON.parse(giftStringified) : undefined;
|
||||
|
||||
const user = await User.findById(metadata.userId).exec();
|
||||
if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId }));
|
||||
|
||||
let method = 'buyGems';
|
||||
const data = {
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: stripeConstants.PAYMENT_METHOD,
|
||||
gemsBlock,
|
||||
gift,
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
if (!member) {
|
||||
throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId: gift.uuid }));
|
||||
}
|
||||
gift.member = member;
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import cc from 'coupon-code';
|
||||
import moment from 'moment';
|
||||
|
||||
import logger from '../../logger';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import shared from '../../../../common';
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import stripeConstants from './constants';
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { getStripeApi } from './api';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../../models/group';
|
||||
import {
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
|
||||
export async function checkSubData (sub, isGroup = false, coupon) {
|
||||
if (!sub || !sub.canSubscribe) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
|
||||
if (
|
||||
(sub.target === 'group' && !isGroup)
|
||||
|| (sub.target === 'user' && isGroup)
|
||||
) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
|
||||
|
||||
if (sub.discount) {
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
|
||||
coupon = await Coupon // eslint-disable-line no-param-reassign
|
||||
.findOne({ _id: cc.validate(coupon), event: sub.key }).exec();
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function applySubscription (session) {
|
||||
const { metadata, customer: customerId, subscription: subscriptionId } = session;
|
||||
const { sub: subStringified, userId, groupId } = metadata;
|
||||
|
||||
const sub = subStringified ? JSON.parse(subStringified) : undefined;
|
||||
|
||||
const user = await User.findById(metadata.userId).exec();
|
||||
if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId }));
|
||||
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: stripeConstants.PAYMENT_METHOD,
|
||||
sub,
|
||||
groupId,
|
||||
subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentMethodChange (session, stripeInc) {
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
const { setup_intent: setupIntent } = session;
|
||||
|
||||
const intent = await stripeApi.setupIntents.retrieve(setupIntent);
|
||||
const { payment_method: paymentMethodId } = intent;
|
||||
const subscriptionId = intent.metadata.subscription_id;
|
||||
|
||||
// Update the payment method on the subscription
|
||||
await stripeApi.subscriptions.update(subscriptionId, {
|
||||
default_payment_method: paymentMethodId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function chargeForAdditionalGroupMember (group, stripeInc) {
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
const plan = shared.content.subscriptionBlocks.group_monthly;
|
||||
|
||||
await stripeApi.subscriptions.update(
|
||||
group.purchased.plan.subscriptionId,
|
||||
{
|
||||
plan: plan.key,
|
||||
quantity: group.memberCount + plan.quantity - 1,
|
||||
},
|
||||
);
|
||||
|
||||
group.purchased.plan.quantity = group.memberCount + plan.quantity - 1;
|
||||
}
|
||||
|
||||
export async function cancelSubscription (options, stripeInc) {
|
||||
const { groupId, user, cancellationReason } = options;
|
||||
let customerId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (groupId) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFound(shared.i18n.t('groupNotFound'));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription'));
|
||||
}
|
||||
customerId = group.purchased.plan.customerId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
|
||||
|
||||
const customer = await stripeApi
|
||||
.customers.retrieve(customerId, { expand: ['subscriptions'] })
|
||||
.catch(err => logger.error(err, 'Error retrieving customer from Stripe (was likely deleted).'));
|
||||
let nextBill = moment().add(30, 'days').unix() * 1000;
|
||||
|
||||
if (customer && (customer.subscription || customer.subscriptions)) {
|
||||
let { subscription } = customer;
|
||||
if (!subscription && customer.subscriptions) {
|
||||
[subscription] = customer.subscriptions.data;
|
||||
}
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
if (subscription && subscription.current_period_end) {
|
||||
nextBill = subscription.current_period_end * 1000; // timestamp in seconds
|
||||
}
|
||||
}
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
nextBill,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
cancellationReason,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import nconf from 'nconf';
|
||||
import moment from 'moment';
|
||||
|
||||
import logger from '../../logger';
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { getStripeApi } from './api';
|
||||
import {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../../models/group';
|
||||
import shared from '../../../../common';
|
||||
import { applyGemPayment } from './oneTimePayments'; // eslint-disable-line import/no-cycle
|
||||
import { applySubscription, handlePaymentMethodChange } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||
|
||||
const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
|
||||
|
||||
export async function handleWebhooks (options, stripeInc) {
|
||||
const { body, headers } = options;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// Verify the event by fetching it from Stripe
|
||||
event = stripeApi.webhooks.constructEvent(body, headers['stripe-signature'], endpointSecret);
|
||||
} catch (err) {
|
||||
logger.error(new Error('Error verifying Stripe webhook'), { err });
|
||||
throw new BadRequest(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'payment_intent.created':
|
||||
case 'payment_intent.succeeded':
|
||||
case 'payment_intent.payment_failed':
|
||||
case 'setup_intent.created':
|
||||
case 'setup_intent.succeeded':
|
||||
case 'charge.succeeded':
|
||||
case 'charge.failed':
|
||||
case 'payment_method.attached':
|
||||
case 'customer.created':
|
||||
case 'customer.updated':
|
||||
case 'customer.deleted':
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
case 'invoiceitem.created':
|
||||
case 'invoice.created':
|
||||
case 'invoice.updated':
|
||||
case 'invoice.finalized':
|
||||
case 'invoice.paid':
|
||||
case 'invoice.upcoming':
|
||||
case 'invoice.payment_succeeded': {
|
||||
// Events sent even if not active in the Stripe dashboard when a payment is being made
|
||||
// This is to avoid error logs from the webhook handler not being implemented
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
const { metadata } = session;
|
||||
|
||||
if (metadata.type === 'edit-card-group' || metadata.type === 'edit-card-user') {
|
||||
await handlePaymentMethodChange(session);
|
||||
} else if (metadata.type !== 'subscription') {
|
||||
await applyGemPayment(session);
|
||||
} else {
|
||||
await applySubscription(session);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
// event.request !== null means that the user itself cancelled the subscrioption,
|
||||
// the cancellation on our side has been already handled
|
||||
if (event.request !== null) break;
|
||||
|
||||
const subscription = event.data.object;
|
||||
const customerId = subscription.customer;
|
||||
const isGroupSub = shared.content.subscriptionBlocks[subscription.plan.id].target === 'group';
|
||||
|
||||
let user;
|
||||
let groupId;
|
||||
|
||||
if (isGroupSub) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).select(groupFields).exec();
|
||||
|
||||
if (!group) throw new NotFound(shared.i18n.t('groupNotFound'));
|
||||
groupId = group._id;
|
||||
|
||||
user = await User.findById(group.leader).exec();
|
||||
} else {
|
||||
user = await User.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
if (!user) throw new NotFound(shared.i18n.t('userNotFound'));
|
||||
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
// Give three extra days to allow the user to resubscribe without losing benefits
|
||||
nextBill: moment().add({ days: 3 }).toDate(),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new BadRequest(`Missing handler for Stripe webhook ${event.type}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(new Error('Error handling Stripe webhook'), { event, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,16 @@ export default function attachMiddlewares (app, server) {
|
||||
app.use(bodyParser.urlencoded({
|
||||
extended: true, // Uses 'qs' library as old connect middleware
|
||||
}));
|
||||
app.use(bodyParser.json());
|
||||
app.use(function bodyMiddleware (req, res, next) { // eslint-disable-line prefer-arrow-callback
|
||||
if (req.path === '/stripe/webhooks') {
|
||||
// Do not parse the body for `/stripe/webhooks`
|
||||
// See https://stripe.com/docs/webhooks/signatures#verify-official-libraries
|
||||
bodyParser.raw({ type: 'application/json' })(req, res, next);
|
||||
} else {
|
||||
bodyParser.json()(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(methodOverride());
|
||||
|
||||
app.use(cookieSession({
|
||||
|
||||
Reference in New Issue
Block a user