Merge branch 'feature-stripe-tiers' into feature-stripe-tiers-styling

This commit is contained in:
Ash Monty
2022-07-09 10:52:19 +02:00
14 changed files with 705 additions and 187 deletions

View File

@@ -1,10 +1,14 @@
const Trello =require('trello');
const Trello = require('trello');
const Stripe = require('stripe');
const got = require('got');
const config = require('../config.json');
const trello = new Trello(config.trello.api_key, config.trello.api_token);
const stripe = new Stripe(config.stripe.secret_key);
const VALID_LIST_NAMES = ['Not Started', 'Started', 'Completed'];
let cache;
let trelloCache;
let stripeDonationCache;
async function getTrelloCache() {
const available = await trelloAPIAvailable();
@@ -15,19 +19,19 @@ async function getTrelloCache() {
};
}
if (!cache) {
cache = await updateTrelloCache();
if (!trelloCache) {
trelloCache = await updateTrelloCache();
}
if (cache.update_time < Date.now() - (1000 * 60 * 60)) {
cache = await updateTrelloCache();
if (trelloCache.update_time < Date.now() - (1000 * 60 * 60)) {
trelloCache = await updateTrelloCache();
}
return cache;
return trelloCache;
}
async function updateTrelloCache() {
const progressData = {
const progressCache = {
update_time: Date.now(),
sections: []
};
@@ -70,11 +74,11 @@ async function updateTrelloCache() {
}
if (meta.progress.not_started.length !== 0 || meta.progress.started.length !== 0 || meta.progress.completed.length !== 0) {
progressData.sections.push(meta);
progressCache.sections.push(meta);
}
}
return progressData;
return progressCache;
}
async function trelloAPIAvailable() {
@@ -82,7 +86,55 @@ async function trelloAPIAvailable() {
return status.description === 'All Systems Operational';
}
async function getStripeDonationCache() {
if (!stripeDonationCache) {
stripeDonationCache = await updateStripeDonationCache();
}
if (stripeDonationCache.update_time < Date.now() - (1000 * 60 * 60)) {
stripeDonationCache = await updateStripeDonationCache();
}
return stripeDonationCache;
}
async function updateStripeDonationCache() {
const donationCache = {
update_time: Date.now(),
goal: config.stripe.goal_cents,
total: 0,
donators: 0,
completed_real: 0,
completed_capped: 0
};
let hasMore = true;
let lastId;
while (hasMore) {
const { data: activeSubscriptions, has_more } = await stripe.subscriptions.list({
limit: 100,
status: 'active',
starting_after: lastId
});
donationCache.donators += activeSubscriptions.length;
for (const subscription of activeSubscriptions) {
donationCache.total += subscription.plan.amount;
lastId = subscription.id;
}
hasMore = has_more;
}
donationCache.completed_real = Math.floor((donationCache.total / donationCache.goal) * 100); // real completion amount
donationCache.completed_capped = Math.max(0, Math.min(donationCache.completed_real, 100)); // capped at 100
return donationCache;
}
module.exports = {
getTrelloCache,
updateTrelloCache
getStripeDonationCache
};

22
src/mailer.js Normal file
View File

@@ -0,0 +1,22 @@
const nodemailer = require('nodemailer');
const config = require('../config.json');
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: config.gmail.user,
pass: config.gmail.pass
}
});
async function sendMail(options) {
options.from = config.gmail.from;
return await transporter.sendMail(options);
}
module.exports = {
sendMail
};

View File

@@ -5,7 +5,9 @@ const { v4: uuidv4 } = require('uuid');
const AdmZip = require('adm-zip');
const Stripe = require('stripe');
const database = require('../database');
const cache = require('../cache');
const util = require('../util');
const logger = require('../logger');
const config = require('../../config.json');
const { Router } = express;
@@ -28,31 +30,28 @@ router.get('/', async (request, response) => {
return response.redirect('/account/login');
}
const { upgrade_success } = request.query;
console.log(upgrade_success);
const stripe = {};
if (upgrade_success === 'true') {
stripe.showNotice = true;
stripe.success = true;
} else if (upgrade_success === 'false') {
stripe.showNotice = true;
stripe.error = true;
}
// Setup the data to be sent to the handlebars renderer
const renderData = {
layout: 'main',
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
linked: request.cookies.linked,
error: request.cookies.error,
stripe: stripe
success: request.cookies.success,
error: request.cookies.error
};
// Reset message cookies
response.clearCookie('linked', { domain: '.pretendo.network' });
response.clearCookie('success', { domain: '.pretendo.network' });
response.clearCookie('error', { domain: '.pretendo.network' });
// Check for Stripe messages
const { upgrade_success } = request.query;
if (upgrade_success === 'true') {
renderData.success = 'Account upgraded successfully';
} else if (upgrade_success === 'false') {
renderData.error = 'Account upgrade failed';
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
@@ -93,7 +92,12 @@ router.get('/', async (request, response) => {
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
const pnid = await database.PNID.findOne({ pid });
renderData.tierName = pnid.get('connections.stripe.tier_name');
renderData.tierLevel = pnid.get('connections.stripe.tier_level');
renderData.account = account;
renderData.isTester = account.access_level > 0;
@@ -375,7 +379,7 @@ router.get('/connect/discord', async (request, response) => {
}
}
response.cookie('linked', 'Discord', { domain: '.pretendo.network' }).redirect('/account');
response.cookie('success', 'Discord account linked successfully', { domain: '.pretendo.network' }).redirect('/account');
});
router.get('/online-files', async (request, response) => {
@@ -529,11 +533,51 @@ router.get('/upgrade', async (request, response) => {
return response.redirect('/account/login');
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' });
response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' });
response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' });
apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
const pnid = await database.PNID.findOne({ pid });
const renderData = {
layout: 'main',
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
error: request.cookies.error
error: request.cookies.error,
currentTier: pnid.get('connections.stripe.price_id'),
donationCache: await cache.getStripeDonationCache()
};
const { data: prices } = await stripe.prices.list();
@@ -567,7 +611,7 @@ router.get('/upgrade', async (request, response) => {
response.render('account/upgrade', renderData);
});
router.post('/checkout/:priceId', async (request, response) => {
router.post('/stripe/checkout/:priceId', async (request, response) => {
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token || !request.cookies.ph) {
return response.redirect('/account/login');
@@ -627,16 +671,19 @@ router.post('/checkout/:priceId', async (request, response) => {
await database.PNID.updateOne({ pid }, {
$set: {
connections: {
stripe: {
customer_id: customer.id // ensure PNID always has latest customer ID
}
}
'connections.stripe.customer_id': customer.id // ensure PNID always has latest customer ID
}
}, { upsert: true }).exec();
const priceId = request.params.priceId;
const pnid = await database.PNID.findOne({ pid });
if (pnid.get('access_level') >= 2) {
response.cookie('error', 'Staff members do not need to purchase tiers', { domain: '.pretendo.network' });
return response.redirect('/account');
}
try {
const session = await stripe.checkout.sessions.create({
line_items: [
@@ -654,13 +701,90 @@ router.post('/checkout/:priceId', async (request, response) => {
return response.redirect(303, session.url);
} catch (error) {
// Maybe we need a dedicated error page?
// O handle this as not cookies?
// Or handle this as not cookies?
response.cookie('error', error.message, { domain: '.pretendo.network' });
return response.redirect('/account');
}
});
router.post('/stripe-wh', express.raw({ type: 'application/json' }), async (request, response) => {
router.post('/stripe/unsubscribe', async (request, response) => {
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token || !request.cookies.ph) {
return response.redirect('/account/login');
}
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token, { domain: '.pretendo.network' });
response.cookie('access_token', tokens.access_token, { domain: '.pretendo.network' });
response.cookie('token_type', tokens.token_type, { domain: '.pretendo.network' });
apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
return response.redirect('/account/login');
}
// Set user account info to render data
const account = apiResponse.body;
const pid = account.pid;
const pnid = await database.PNID.findOne({ pid });
const subscriptionId = pnid.get('connections.stripe.subscription_id');
const tierName = pnid.get('connections.stripe.tier_name');
if (subscriptionId) {
try {
await stripe.subscriptions.del(subscriptionId);
const updateData = {
'connections.stripe.subscription_id': null,
'connections.stripe.price_id': null,
'connections.stripe.tier_level': 0,
'connections.stripe.tier_name': null,
};
if (pnid.get('access_level') < 2) {
// Fail-safe for if staff members reach here
// Mostly only useful during testing
updateData.access_level = 0;
}
await database.PNID.updateOne({ pid }, { $set: updateData }).exec();
} catch (error) {
logger.error(`Error canceling old user subscription | ${pnid.get('connections.stripe.customer_id')}, ${pid}, ${subscriptionId} | - ${error.message}`);
response.cookie('error', 'Error canceling subscription! Contact support if issue persists', { domain: '.pretendo.network' });
return response.redirect('/account');
}
}
response.cookie('success', `Unsubscribed from ${tierName}`, { domain: '.pretendo.network' });
return response.redirect('/account');
});
router.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (request, response) => {
const stripeSignature = request.headers['stripe-signature'];
let event;

View File

@@ -3,7 +3,7 @@ const util = require('../util');
const { boards } = require('../../boards/boards.json');
const router = new Router();
const { getTrelloCache } = require('../trello');
const { getTrelloCache } = require('../cache');
router.get('/', async (request, response) => {

View File

@@ -3,7 +3,7 @@ const util = require('../util');
const { boards } = require('../../boards/boards.json');
const router = new Router();
const { getTrelloCache } = require('../trello');
const { getTrelloCache } = require('../cache');
router.get('/', async (request, response) => {
const renderData = {

View File

@@ -11,8 +11,10 @@ const PNIDSchema = new Schema({
connections: {
stripe: {
customer_id: String,
subscription_id: String,
price_id: String,
tier_level: Number,
tier_name: String,
latest_webhook_timestamp: Number
}
}

View File

@@ -1,14 +1,13 @@
const fs = require('fs-extra');
const got = require('got');
const crypto = require('crypto');
const gmail = require('gmail-send');
const Stripe = require('stripe');
const mailer = require('./mailer');
const database = require('./database');
const logger = require('./logger');
const config = require('../config.json');
const stripe = new Stripe(config.stripe.secret_key);
const sendGmail = gmail(config.gmail);
function fullUrl(request) {
return `${request.protocol}://${request.hostname}${request.originalUrl}`;
@@ -81,27 +80,31 @@ async function handleStripeEvent(event) {
const product = await stripe.products.retrieve(subscription.plan.product);
const customer = await stripe.customers.retrieve(subscription.customer);
if (!customer.metadata.pnid_pid && subscription.status !== 'canceled' && subscription.status !== 'unpaid') {
// No PNID PID linked to customer. Abort and refund!
logger.error(`Stripe user ${customer.id} has no PNID linked! Refunding order`);
if (!customer?.metadata?.pnid_pid) {
// No PNID PID linked to customer
if (subscription.status !== 'canceled' && subscription.status !== 'unpaid') {
// Abort and refund!
logger.error(`Stripe user ${customer.id} has no PNID linked! Refunding order`);
await stripe.subscriptions.del(subscription.id);
await stripe.subscriptions.del(subscription.id);
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice);
await stripe.refunds.create({
payment_intent: invoice.payment_intent
});
try {
await sendGmail({
to: customer.email,
subject: 'Pretendo Subscription Failed - No Linked PNID',
text: `Your recent subscription to Pretendo Network has failed.\nThis is due to no PNID PID being linked to the Stripe customer account used. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}`
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice);
await stripe.refunds.create({
payment_intent: invoice.payment_intent
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email} | - ${error.message}`);
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription Failed - No Linked PNID',
text: `Your recent subscription to Pretendo Network has failed.\nThis is due to no PNID PID being linked to the Stripe customer account used. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}`
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email} | - ${error.message}`);
}
} else {
logger.error(`Stripe user ${customer.id} has no PNID linked!`);
}
return;
}
@@ -128,9 +131,9 @@ async function handleStripeEvent(event) {
});
try {
await sendGmail({
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Subscription Failed - PNID Not Found',
subject: 'Pretendo Network Subscription Failed - PNID Not Found',
text: `Your recent subscription to Pretendo Network has failed.\nThis is due to the provided PNID not being found. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}\nPNID PID: ${pid}`
});
} catch (error) {
@@ -140,14 +143,31 @@ async function handleStripeEvent(event) {
return;
}
const updateData = {
connections: {
stripe: {
price_id: subscription.plan.id,
tier_level: Number(product.metadata.tier_level || 0),
latest_webhook_timestamp: event.created
const currentSubscriptionId = pnid.get('connections.stripe.subscription_id');
if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) {
// Canceling old subscription, do nothing but update webhook date
const updateData = {
'connections.stripe.latest_webhook_timestamp': event.created
};
await database.PNID.updateOne({
pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: event.created
}
}
}, { $set: updateData }).exec();
return;
}
const updateData = {
'connections.stripe.subscription_id': subscription.status === 'active' ? subscription.id : null,
'connections.stripe.price_id': subscription.status === 'active' ? subscription.plan.id : null,
'connections.stripe.tier_level': subscription.status === 'active' ? Number(product.metadata.tier_level || 0) : 0,
'connections.stripe.tier_name': subscription.status === 'active' ? product.name : null,
'connections.stripe.latest_webhook_timestamp': event.created,
};
if (product.metadata.beta === 'true') {
@@ -168,8 +188,69 @@ async function handleStripeEvent(event) {
default:
break;
}
}
await database.PNID.updateOne({ pid }, { $set: updateData }, { upsert: true }).exec();
await database.PNID.updateOne({
pid,
'connections.stripe.latest_webhook_timestamp': {
$lte: event.created
}
}, { $set: updateData }).exec();
if (subscription.status === 'active') {
// Get all the customers active subscriptions
const { data: activeSubscriptions } = await stripe.subscriptions.list({
limit: 100,
status: 'active',
customer: customer.id
});
// Order subscriptions by creation time and remove the latest one
const orderedActiveSubscriptions = activeSubscriptions.sort((a, b) => b.created - a.created);
const pastSubscriptions = orderedActiveSubscriptions.slice(1);
// Remove any old past subscriptions that might still be hanging around
for (const pastSubscription of pastSubscriptions) {
try {
await stripe.subscriptions.del(pastSubscription.id);
} catch (error) {
logger.error(`Error canceling old user subscription | ${customer.id}, ${pid}, ${pastSubscription.id} | - ${error.message}`);
}
}
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription - Active',
text: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!`
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`);
}
}
if (subscription.status === 'canceled') {
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription - Canceled',
text: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! `
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`);
}
}
if (subscription.status === 'unpaid') {
try {
await mailer.sendMail({
to: customer.email,
subject: 'Pretendo Network Subscription - Unpaid',
text: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! `
});
} catch (error) {
logger.error(`Error sending email | ${customer.id}, ${customer.email}, ${pid} | - ${error.message}`);
}
}
}
}