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:
parent
447cb2d92d
commit
8910c87d15
@ -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;
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
43
packages/server/public/js/stripe_utils.js
Normal file
43
packages/server/public/js/stripe_utils.js
Normal 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);
|
||||
// }
|
@ -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 || '',
|
||||
};
|
||||
|
@ -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;
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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(),
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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 });
|
||||
},
|
||||
|
||||
};
|
||||
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)}`;
|
||||
}
|
||||
|
@ -81,6 +81,7 @@ export async function beforeAllDb(unitName: string) {
|
||||
|
||||
await initConfig(Env.Dev, {
|
||||
SQLITE_DATABASE: createdDbPath_,
|
||||
SUPPORT_EMAIL: 'testing@localhost',
|
||||
}, {
|
||||
tempDir: tempDir,
|
||||
});
|
||||
|
@ -77,6 +77,7 @@ export interface MailerConfig {
|
||||
}
|
||||
|
||||
export interface StripeConfig extends StripePublicConfig {
|
||||
enabled: boolean;
|
||||
secretKey: string;
|
||||
webhookSecret: string;
|
||||
}
|
||||
|
9
packages/server/src/utils/views/stripe.ts
Normal file
9
packages/server/src/utils/views/stripe.ts
Normal 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;
|
||||
}
|
26
packages/server/src/views/emails/endOfBetaTemplate.ts
Normal file
26
packages/server/src/views/emails/endOfBetaTemplate.ts
Normal 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(),
|
||||
};
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user