1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Server: Handle beta user upgrade

This commit is contained in:
Laurent Cozic 2021-08-02 17:43:18 +01:00
parent 447cb2d92d
commit 8910c87d15
18 changed files with 370 additions and 34 deletions

View File

@ -886,6 +886,10 @@ footer .right-links a {
margin-bottom: 1em;
}
.plan-group {
justify-content: center;
}
.plan-group .plan-price-yearly-per-year {
display: flex;
justify-content: flex-end;

View File

@ -29,7 +29,7 @@
{{/featuresOff}}
<p class="text-center subscribe-wrapper">
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white">{{cfaLabel}}</a>
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton">{{cfaLabel}}</a>
</p>
</div>

View File

@ -11,6 +11,12 @@
</div>
</div>
<noscript>
<div class="alert alert-danger alert-env-dev" role="alert" style='text-align: center; margin-top: 10px;'>
To use this page please enable JavaScript!
</div>
</noscript>
<div style="display: flex; justify-content: center; margin-top: 1.2em">
<div class="form-check form-check-inline">
<input id="pay-monthly-radio" class="form-check-input" type="radio" name="pay-radio" checked value="monthly">
@ -49,11 +55,31 @@
<script src="https://js.stripe.com/v3/"></script>
<script>
const urlQuery = new URLSearchParams(location.search);
let subscriptionPeriod = 'monthly';
var stripe = Stripe('{{{stripeConfig.publishableKey}}}');
let checkoutSessionUser = null;
// Temporary setup to allow Beta users to start their subscription.
function setupBetaHandling(query) {
let accountType = Number(query.get('account_type'));
if (isNaN(accountType)) accountType = 1;
const email = query.get('email');
if (!email) return;
$('.account-type-3').css('display', 'none');
$('.subscribeButton').text('Buy now');
if (accountType === 2) {
$('.account-type-1').css('display', 'none');
}
checkoutSessionUser = { email, accountType };
}
var createCheckoutSession = function(priceId) {
const urlQuery = new URLSearchParams(location.search);
const coupon = urlQuery.get('coupon') || '';
console.info('Creating Stripe session for price:', priceId, 'Coupon:', coupon);
@ -66,6 +92,7 @@
body: JSON.stringify({
priceId: priceId,
coupon: coupon,
email: checkoutSessionUser ? checkoutSessionUser.email : '',
})
}).then(async function(result) {
if (!result.ok) {
@ -84,7 +111,9 @@
$('.plan-group').removeClass(period === 'monthly' ? 'plan-prices-yearly' : 'plan-prices-monthly');
$('.plan-group').addClass('plan-prices-' + period);
})
});
setupBetaHandling(urlQuery);
});
</script>
</div>

View File

@ -0,0 +1,43 @@
// function stripeConfig() {
// if (!joplin || !joplin.stripeConfig) throw new Error('Stripe config is not set');
// return joplin.stripeConfig;
// }
// function newStripe() {
// return Stripe(stripeConfig().publishableKey);
// }
// async function createStripeCheckoutSession(priceId) {
// const urlQuery = new URLSearchParams(location.search);
// const coupon = urlQuery.get('coupon') || '';
// console.info('Creating Stripe session for price:', priceId, 'Coupon:', coupon);
// const result = await fetch(`${stripeConfig().webhookBaseUrl}/stripe/createCheckoutSession`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({
// priceId: priceId,
// coupon: coupon,
// }),
// });
// if (!result.ok) {
// console.error('Could not create Stripe checkout session', await result.text());
// alert('The checkout session could not be created. Please contact support@joplincloud.com for support.');
// } else {
// return result.json();
// }
// }
// async function startStripeCheckout(priceId) {
// const data = await createStripeCheckoutSession(stripeId);
// const result = await stripe.redirectToCheckout({
// sessionId: data.sessionId,
// });
// console.info('Redirected to checkout', result);
// }

View File

@ -102,6 +102,7 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
function stripeConfigFromEnv(publicConfig: StripePublicConfig, env: EnvVariables): StripeConfig {
return {
...publicConfig,
enabled: !!env.STRIPE_SECRET_KEY,
secretKey: env.STRIPE_SECRET_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
};

View File

@ -1,6 +1,8 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
import { EmailSender, User } from '../db';
import { ErrorUnprocessableEntity } from '../utils/errors';
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
import { AccountType } from './UserModel';
describe('UserModel', function() {
@ -86,4 +88,54 @@ describe('UserModel', function() {
expect(email.error).toBe('');
});
test('should send a beta reminder email', async function() {
stripeConfig().enabled = true;
const { user: user1 } = await createUserAndSession(1, false, { email: 'toto@example.com' });
const range = betaUserDateRange();
await models().user().save({
id: user1.id,
created_time: range[0],
account_type: AccountType.Pro,
});
Date.now = jest.fn(() => range[0] + 6912000 * 1000); // 80 days later
await models().user().handleBetaUserEmails();
expect((await models().email().all()).length).toBe(2);
{
const email = (await models().email().all()).pop();
expect(email.recipient_email).toBe('toto@example.com');
expect(email.subject.indexOf('10 days') > 0).toBe(true);
expect(email.body.indexOf('10 days') > 0).toBe(true);
expect(email.body.indexOf('toto%40example.com') > 0).toBe(true);
expect(email.body.indexOf('account_type=2') > 0).toBe(true);
}
await models().user().handleBetaUserEmails();
// It should not send a second email
expect((await models().email().all()).length).toBe(2);
Date.now = jest.fn(() => range[0] + 7603200 * 1000); // 88 days later
await models().user().handleBetaUserEmails();
expect((await models().email().all()).length).toBe(3);
{
const email = (await models().email().all()).pop();
expect(email.subject.indexOf('2 days') > 0).toBe(true);
expect(email.body.indexOf('2 days') > 0).toBe(true);
}
await models().user().handleBetaUserEmails();
expect((await models().email().all()).length).toBe(3);
stripeConfig().enabled = false;
});
});

View File

@ -12,6 +12,15 @@ import { confirmUrl, resetPasswordUrl } from '../utils/urlUtils';
import { checkRepeatPassword, CheckRepeatPasswordInput } from '../routes/index/users';
import accountConfirmationTemplate from '../views/emails/accountConfirmationTemplate';
import resetPasswordTemplate from '../views/emails/resetPasswordTemplate';
import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe';
import endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
interface UserEmailDetails {
sender_id: EmailSender;
recipient_id: Uuid;
recipient_email: string;
recipient_name: string;
}
export enum AccountType {
Default = 0,
@ -261,16 +270,22 @@ export default class UserModel extends BaseModel<User> {
await this.save({ id: user.id, email_confirmed: 1 });
}
private userEmailDetails(user: User): UserEmailDetails {
return {
sender_id: EmailSender.NoReply,
recipient_id: user.id,
recipient_email: user.email,
recipient_name: user.full_name || '',
};
}
public async sendAccountConfirmationEmail(user: User) {
const validationToken = await this.models().token().generate(user.id);
const url = encodeURI(confirmUrl(user.id, validationToken));
await this.models().email().push({
...accountConfirmationTemplate({ url }),
sender_id: EmailSender.NoReply,
recipient_id: user.id,
recipient_email: user.email,
recipient_name: user.full_name || '',
...this.userEmailDetails(user),
});
}
@ -283,10 +298,7 @@ export default class UserModel extends BaseModel<User> {
await this.models().email().push({
...resetPasswordTemplate({ url }),
sender_id: EmailSender.NoReply,
recipient_id: user.id,
recipient_email: user.email,
recipient_name: user.full_name || '',
...this.userEmailDetails(user),
});
}
@ -297,6 +309,44 @@ export default class UserModel extends BaseModel<User> {
await this.models().token().deleteByValue(user.id, token);
}
public async handleBetaUserEmails() {
if (!stripeConfig().enabled) return;
const range = betaUserDateRange();
const betaUsers = await this
.db('users')
.select(['id', 'email', 'full_name', 'account_type', 'created_time'])
.where('created_time', '>=', range[0])
.andWhere('created_time', '<=', range[1]);
const reminderIntervals = [14, 3];
for (const user of betaUsers) {
if (!(await isBetaUser(this.models(), user.id))) continue;
const remainingDays = betaUserTrialPeriodDays(user.created_time, 0, 0);
for (const reminderInterval of reminderIntervals) {
if (remainingDays <= reminderInterval) {
const sentKey = `betaUser::emailSent::${reminderInterval}::${user.id}`;
if (!(await this.models().keyValue().value(sentKey))) {
await this.models().email().push({
...endOfBetaTemplate({
expireDays: remainingDays,
startSubUrl: betaStartSubUrl(user.email, user.account_type),
}),
...this.userEmailDetails(user),
});
await this.models().keyValue().setValue(sentKey, 1);
}
}
}
}
}
private formatValues(user: User): User {
const output: User = { ...user };
if ('email' in output) output.email = user.email.trim().toLowerCase();

View File

@ -10,6 +10,7 @@ import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSize
import { getCanShareFolder, totalSizeClass } from '../../models/utils/user';
import config from '../../config';
import { escapeHtml } from '../../utils/htmlUtils';
import { betaStartSubUrl, betaUserTrialPeriodDays, isBetaUser } from '../../utils/stripe';
const router: Router = new Router(RouteType.Web);
@ -69,6 +70,9 @@ router.get('home', async (_path: SubPath, ctx: AppContext) => {
},
],
showUpgradeProButton: subscription && user.account_type === AccountType.Basic,
showBetaMessage: await isBetaUser(ctx.joplin.models, user.id),
betaExpiredDays: betaUserTrialPeriodDays(user.created_time, 0, 0),
betaStartSubUrl: betaStartSubUrl(user.email, user.account_type),
setupMessageHtml: setupMessageHtml(),
};

View File

@ -1,6 +1,6 @@
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
import { AccountType } from '../../models/UserModel';
import { initStripe, stripeConfig } from '../../utils/stripe';
import { betaUserTrialPeriodDays, initStripe, isBetaUser, stripeConfig } from '../../utils/stripe';
import { beforeAllDb, afterAllTests, beforeEachDb, models, koaAppContext, expectNotThrow } from '../../utils/testing/testUtils';
import uuidgen from '../../utils/uuidgen';
import { postHandlers } from './stripe';
@ -33,6 +33,7 @@ describe('index/stripe', function() {
beforeAll(async () => {
await beforeAllDb('index/stripe');
stripeConfig().enabled = true;
});
afterAll(async () => {
@ -66,4 +67,28 @@ describe('index/stripe', function() {
await expectNotThrow(async () => createUserViaSubscription('toto@example.com', 'evt_1'));
});
test('should check if it is a beta user', async function() {
const user1 = await models().user().save({ email: 'toto@example.com', password: uuidgen() });
const user2 = await models().user().save({ email: 'tutu@example.com', password: uuidgen() });
await models().user().save({ id: user2.id, created_time: 1624441295775 });
expect(await isBetaUser(models(), user1.id)).toBe(false);
expect(await isBetaUser(models(), user2.id)).toBe(true);
await models().subscription().save({
user_id: user2.id,
stripe_user_id: 'usr_111',
stripe_subscription_id: 'sub_111',
last_payment_time: Date.now(),
});
expect(await isBetaUser(models(), user2.id)).toBe(false);
});
test('should find out beta user trial end date', async function() {
const fromDateTime = 1627901594842; // Mon Aug 02 2021 10:53:14 GMT+0000
expect(betaUserTrialPeriodDays(1624441295775, fromDateTime)).toBe(50); // Wed Jun 23 2021 09:41:35 GMT+0000
expect(betaUserTrialPeriodDays(1614682158000, fromDateTime)).toBe(7); // Tue Mar 02 2021 10:49:18 GMT+0000
});
});

View File

@ -9,7 +9,7 @@ import { Stripe } from 'stripe';
import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
import { initStripe, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
import { betaUserTrialPeriodDays, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
import { Subscription } from '../../db';
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
@ -34,6 +34,7 @@ async function stripeEvent(stripe: Stripe, req: any): Promise<Stripe.Event> {
interface CreateCheckoutSessionFields {
priceId: string;
coupon: string;
email: string;
}
type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise<any>;
@ -63,6 +64,8 @@ export const postHandlers: PostHandlers = {
const checkoutSession: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
// Stripe supports many payment method types but it seems only
// "card" is supported for recurring subscriptions.
payment_method_types: ['card'],
line_items: [
{
@ -89,6 +92,20 @@ export const postHandlers: PostHandlers = {
];
}
if (fields.email) {
checkoutSession.customer_email = fields.email.trim();
// If it's a Beta user, we set the trial end period to the end of
// the beta period. So for example if there's 7 weeks left on the
// Beta period, the trial will be 49 days. This is so Beta users can
// setup the subscription at any time without losing the free beta
// period.
const existingUser = await ctx.joplin.models.user().loadByEmail(checkoutSession.customer_email);
if (existingUser && await isBetaUser(ctx.joplin.models, existingUser.id)) {
checkoutSession.subscription_data.trial_period_days = betaUserTrialPeriodDays(existingUser.created_time);
}
}
// See https://stripe.com/docs/api/checkout/sessions/create
// for additional parameters to pass.
const session = await stripe.checkout.sessions.create(checkoutSession);
@ -100,9 +117,7 @@ export const postHandlers: PostHandlers = {
// can create the right account, either Basic or Pro.
await ctx.joplin.models.keyValue().setValue(`stripeSessionToPriceId::${session.id}`, priceId);
return {
sessionId: session.id,
};
return { sessionId: session.id };
},
// # How to test the complete workflow locally
@ -126,15 +141,17 @@ export const postHandlers: PostHandlers = {
webhook: async (stripe: Stripe, _path: SubPath, ctx: AppContext, event: Stripe.Event = null, logErrors: boolean = true) => {
event = event ? event : await stripeEvent(stripe, ctx.req);
const models = ctx.joplin.models;
// Webhook endpoints might occasionally receive the same event more than
// once.
// https://stripe.com/docs/webhooks/best-practices#duplicate-events
const eventDoneKey = `stripeEventDone::${event.id}`;
if (await ctx.joplin.models.keyValue().value<number>(eventDoneKey)) {
if (await models.keyValue().value<number>(eventDoneKey)) {
logger.info(`Skipping event that has already been done: ${event.id}`);
return;
}
await ctx.joplin.models.keyValue().setValue(eventDoneKey, 1);
await models.keyValue().setValue(eventDoneKey, 1);
const hooks: any = {
@ -206,7 +223,7 @@ export const postHandlers: PostHandlers = {
let accountType = AccountType.Basic;
try {
const priceId: string = await ctx.joplin.models.keyValue().value(`stripeSessionToPriceId::${checkoutSession.id}`);
const priceId: string = await models.keyValue().value(`stripeSessionToPriceId::${checkoutSession.id}`);
accountType = priceIdToAccountType(priceId);
logger.info('Price ID:', priceId);
} catch (error) {
@ -224,13 +241,37 @@ export const postHandlers: PostHandlers = {
const stripeUserId = checkoutSession.customer as string;
const stripeSubscriptionId = checkoutSession.subscription as string;
await ctx.joplin.models.subscription().saveUserAndSubscription(
userEmail,
customerName,
accountType,
stripeUserId,
stripeSubscriptionId
);
const existingUser = await models.user().loadByEmail(userEmail);
if (existingUser) {
if (await isBetaUser(models, existingUser.id)) {
logger.info(`Setting up Beta user subscription: ${existingUser.email}`);
// First set the account type correctly (in case the
// user also upgraded or downgraded their account)
await models.user().save({ id: existingUser.id, account_type: accountType });
// Then save the subscription
await models.subscription().save({
user_id: existingUser.id,
stripe_user_id: stripeUserId,
stripe_subscription_id: stripeSubscriptionId,
last_payment_time: Date.now(),
});
} else {
// TODO: Some users accidentally subscribe multiple
// times - in that case, cancel the subscription and
// don't do anything more.
}
} else {
await models.subscription().saveUserAndSubscription(
userEmail,
customerName,
accountType,
stripeUserId,
stripeSubscriptionId
);
}
},
'invoice.paid': async () => {
@ -246,7 +287,7 @@ export const postHandlers: PostHandlers = {
// saved in checkout.session.completed.
const invoice = event.data.object as Stripe.Invoice;
await ctx.joplin.models.subscription().handlePayment(invoice.subscription as string, true);
await models.subscription().handlePayment(invoice.subscription as string, true);
},
'invoice.payment_failed': async () => {
@ -258,7 +299,7 @@ export const postHandlers: PostHandlers = {
const invoice = event.data.object as Stripe.Invoice;
const subId = invoice.subscription as string;
await ctx.joplin.models.subscription().handlePayment(subId, false);
await models.subscription().handlePayment(subId, false);
},
'customer.subscription.deleted': async () => {
@ -266,8 +307,8 @@ export const postHandlers: PostHandlers = {
// by the user. In that case, we disable the user.
const { sub } = await getSubscriptionInfo(event, ctx);
await ctx.joplin.models.user().enable(sub.user_id, false);
await ctx.joplin.models.subscription().toggleSoftDelete(sub.id, true);
await models.user().enable(sub.user_id, false);
await models.subscription().toggleSoftDelete(sub.id, true);
},
'customer.subscription.updated': async () => {
@ -276,11 +317,11 @@ export const postHandlers: PostHandlers = {
const { sub, stripeSub } = await getSubscriptionInfo(event, ctx);
const newAccountType = priceIdToAccountType(stripeSub.items.data[0].price.id);
const user = await ctx.joplin.models.user().load(sub.user_id, { fields: ['id'] });
const user = await models.user().load(sub.user_id, { fields: ['id'] });
if (!user) throw new Error(`No such user: ${user.id}`);
logger.info(`Updating subscription of user ${user.id} to ${newAccountType}`);
await ctx.joplin.models.user().save({ id: user.id, account_type: newAccountType });
await models.user().save({ id: user.id, account_type: newAccountType });
},
};

View File

@ -25,6 +25,10 @@ export default class CronService extends BaseService {
cron.schedule('0 * * * *', async () => {
await runCronTask('updateTotalSizes', async () => this.models.item().updateTotalSizes());
});
cron.schedule('0 12 * * *', async () => {
await runCronTask('handleBetaUserEmails', async () => this.models.user().handleBetaUserEmails());
});
}
}

View File

@ -101,3 +101,38 @@ export async function updateSubscriptionType(models: Models, userId: Uuid, newAc
const stripe = initStripe();
await stripe.subscriptions.update(sub.stripe_subscription_id, { items });
}
export function betaUserDateRange(): number[] {
return [1623785440603, 1626690298054];
}
export async function isBetaUser(models: Models, userId: Uuid): Promise<boolean> {
if (!stripeConfig().enabled) return false;
const user = await models.user().load(userId, { fields: ['created_time'] });
if (!user) throw new Error(`No such user: ${userId}`);
const range = betaUserDateRange();
if (user.created_time > range[1]) return false; // approx 19/07/2021 11:24
if (user.created_time < range[0]) return false;
const sub = await models.subscription().byUserId(userId);
return !sub;
}
export function betaUserTrialPeriodDays(userCreatedTime: number, fromDateTime: number = 0, minDays: number = 7): number {
fromDateTime = fromDateTime ? fromDateTime : Date.now();
const oneDayMs = 86400 * 1000;
const oneMonthMs = oneDayMs * 30;
const endOfBetaPeriodMs = userCreatedTime + oneMonthMs * 3;
const remainingTimeMs = endOfBetaPeriodMs - fromDateTime;
const remainingTimeDays = Math.ceil(remainingTimeMs / oneDayMs);
// Stripe requires a minimum of 48 hours, but let's put 7 days to be sure
return remainingTimeDays < minDays ? minDays : remainingTimeDays;
}
export function betaStartSubUrl(email: string, accountType: AccountType): string {
return `https://joplinapp.org/plans/?email=${encodeURIComponent(email)}&account_type=${encodeURIComponent(accountType)}`;
}

View File

@ -81,6 +81,7 @@ export async function beforeAllDb(unitName: string) {
await initConfig(Env.Dev, {
SQLITE_DATABASE: createdDbPath_,
SUPPORT_EMAIL: 'testing@localhost',
}, {
tempDir: tempDir,
});

View File

@ -77,6 +77,7 @@ export interface MailerConfig {
}
export interface StripeConfig extends StripePublicConfig {
enabled: boolean;
secretKey: string;
webhookSecret: string;
}

View File

@ -0,0 +1,9 @@
import { View } from '../../services/MustacheService';
import { stripeConfig } from '../stripe';
export default function setupStripeView(view: View) {
view.jsFiles.push('stripe_utils');
view.content.stripeConfig = stripeConfig();
view.content.stripeConfigJson = JSON.stringify(stripeConfig());
return view;
}

View File

@ -0,0 +1,26 @@
import config from '../../config';
import { EmailSubjectBody } from '../../models/EmailModel';
interface TemplateView {
expireDays: number;
startSubUrl: string;
}
export default function(view: TemplateView): EmailSubjectBody {
return {
subject: `Your ${config().appName} beta account will expire in ${view.expireDays} days`,
body: `
Your ${config().appName} beta account will expire in ${view.expireDays} days.
To continue using it after this date, please start the subscription by following the link below.
From that page, select either monthly or yearly payments and click "Buy now".
${view.startSubUrl}
If you have any question please contact support at ${config().supportEmail}.
`.trim(),
};
}

View File

@ -1,3 +1,12 @@
{{#showBetaMessage}}
<div class="notification is-warning">
<p class="block">This is a free beta account that will expire in <strong>{{betaExpiredDays}} day(s).</strong></p>
<p class="block">To continue using it after this date, please start the subscription by clicking on the button below.</p>
<p class="block">From the next screen, select either monthly or yearly payments and click "Buy now".</p>
<a href="{{{betaStartSubUrl}}}" class="button is-link">Start Subscription</a>
</div>
{{/showBetaMessage}}
<h2 class="title">Welcome to {{global.appName}}</h2>
<div class="block readable-block">
<p class="block">To start using {{global.appName}}, make sure to <a href="https://joplinapp.org/download">download one of the Joplin applications</a>, either for desktop or for your mobile phone.</p>

View File

@ -18,10 +18,12 @@
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
</div>
<div class="navbar-end">
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">{{global.userDisplayName}}</a>
<div class="navbar-item">
<a class="button is-info navbar-item" href="{{{global.baseUrl}}}/users/me">{{global.userDisplayName}}</a>
</div>
<div class="navbar-item">
<form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
<button class="button is-dark">Logout</button>
</form>
</div>
</div>