From 8910c87d15f49d0aed2770496bf64d184e678c24 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 2 Aug 2021 17:43:18 +0100 Subject: [PATCH] Server: Handle beta user upgrade --- Assets/WebsiteAssets/css/site.css | 4 + .../templates/partials/plan.mustache | 2 +- Assets/WebsiteAssets/templates/plans.mustache | 33 +++++++- packages/server/public/js/stripe_utils.js | 43 ++++++++++ packages/server/src/config.ts | 1 + packages/server/src/models/UserModel.test.ts | 52 ++++++++++++ packages/server/src/models/UserModel.ts | 66 +++++++++++++-- packages/server/src/routes/index/home.ts | 4 + .../server/src/routes/index/stripe.test.ts | 27 ++++++- packages/server/src/routes/index/stripe.ts | 81 ++++++++++++++----- packages/server/src/services/CronService.ts | 4 + packages/server/src/utils/stripe.ts | 35 ++++++++ .../server/src/utils/testing/testUtils.ts | 1 + packages/server/src/utils/types.ts | 1 + packages/server/src/utils/views/stripe.ts | 9 +++ .../src/views/emails/endOfBetaTemplate.ts | 26 ++++++ packages/server/src/views/index/home.mustache | 9 +++ .../server/src/views/partials/navbar.mustache | 6 +- 18 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 packages/server/public/js/stripe_utils.js create mode 100644 packages/server/src/utils/views/stripe.ts create mode 100644 packages/server/src/views/emails/endOfBetaTemplate.ts diff --git a/Assets/WebsiteAssets/css/site.css b/Assets/WebsiteAssets/css/site.css index 6cc76ea37..ee7ed949f 100644 --- a/Assets/WebsiteAssets/css/site.css +++ b/Assets/WebsiteAssets/css/site.css @@ -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; diff --git a/Assets/WebsiteAssets/templates/partials/plan.mustache b/Assets/WebsiteAssets/templates/partials/plan.mustache index afd4ba126..31dcbe9e6 100644 --- a/Assets/WebsiteAssets/templates/partials/plan.mustache +++ b/Assets/WebsiteAssets/templates/partials/plan.mustache @@ -29,7 +29,7 @@ {{/featuresOff}}

- {{cfaLabel}} +

diff --git a/Assets/WebsiteAssets/templates/plans.mustache b/Assets/WebsiteAssets/templates/plans.mustache index 00383d37d..7cdcac0c2 100644 --- a/Assets/WebsiteAssets/templates/plans.mustache +++ b/Assets/WebsiteAssets/templates/plans.mustache @@ -11,6 +11,12 @@ + +
@@ -49,11 +55,31 @@
diff --git a/packages/server/public/js/stripe_utils.js b/packages/server/public/js/stripe_utils.js new file mode 100644 index 000000000..ea55390c8 --- /dev/null +++ b/packages/server/public/js/stripe_utils.js @@ -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); +// } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index a25a51d4a..5702670c5 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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 || '', }; diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts index 9557e5668..8a7695726 100644 --- a/packages/server/src/models/UserModel.test.ts +++ b/packages/server/src/models/UserModel.test.ts @@ -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; + }); + }); diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index ebbf0f7eb..c7e97ee63 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -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 { 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 { 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 { 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(); diff --git a/packages/server/src/routes/index/home.ts b/packages/server/src/routes/index/home.ts index f660591cd..a0dd15218 100644 --- a/packages/server/src/routes/index/home.ts +++ b/packages/server/src/routes/index/home.ts @@ -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(), }; diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts index 4d09f9913..058572117 100644 --- a/packages/server/src/routes/index/stripe.test.ts +++ b/packages/server/src/routes/index/stripe.test.ts @@ -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 + }); + }); diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index f7b0d6118..6e5562a08 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -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 { interface CreateCheckoutSessionFields { priceId: string; coupon: string; + email: string; } type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise; @@ -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(eventDoneKey)) { + if (await models.keyValue().value(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 }); }, }; diff --git a/packages/server/src/services/CronService.ts b/packages/server/src/services/CronService.ts index 96944cf6f..c1cf087d7 100644 --- a/packages/server/src/services/CronService.ts +++ b/packages/server/src/services/CronService.ts @@ -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()); + }); } } diff --git a/packages/server/src/utils/stripe.ts b/packages/server/src/utils/stripe.ts index 1feff9397..25da8c4ff 100644 --- a/packages/server/src/utils/stripe.ts +++ b/packages/server/src/utils/stripe.ts @@ -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 { + 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)}`; +} diff --git a/packages/server/src/utils/testing/testUtils.ts b/packages/server/src/utils/testing/testUtils.ts index 1bce65ec9..8e4a4754b 100644 --- a/packages/server/src/utils/testing/testUtils.ts +++ b/packages/server/src/utils/testing/testUtils.ts @@ -81,6 +81,7 @@ export async function beforeAllDb(unitName: string) { await initConfig(Env.Dev, { SQLITE_DATABASE: createdDbPath_, + SUPPORT_EMAIL: 'testing@localhost', }, { tempDir: tempDir, }); diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts index 258ff6c23..9f1438535 100644 --- a/packages/server/src/utils/types.ts +++ b/packages/server/src/utils/types.ts @@ -77,6 +77,7 @@ export interface MailerConfig { } export interface StripeConfig extends StripePublicConfig { + enabled: boolean; secretKey: string; webhookSecret: string; } diff --git a/packages/server/src/utils/views/stripe.ts b/packages/server/src/utils/views/stripe.ts new file mode 100644 index 000000000..3bf59fad6 --- /dev/null +++ b/packages/server/src/utils/views/stripe.ts @@ -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; +} diff --git a/packages/server/src/views/emails/endOfBetaTemplate.ts b/packages/server/src/views/emails/endOfBetaTemplate.ts new file mode 100644 index 000000000..06eafa3f3 --- /dev/null +++ b/packages/server/src/views/emails/endOfBetaTemplate.ts @@ -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(), + }; +} diff --git a/packages/server/src/views/index/home.mustache b/packages/server/src/views/index/home.mustache index d5f15ee4c..e486ef946 100644 --- a/packages/server/src/views/index/home.mustache +++ b/packages/server/src/views/index/home.mustache @@ -1,3 +1,12 @@ +{{#showBetaMessage}} +
+

This is a free beta account that will expire in {{betaExpiredDays}} day(s).

+

To continue using it after this date, please start the subscription by clicking on the button below.

+

From the next screen, select either monthly or yearly payments and click "Buy now".

+ Start Subscription +
+{{/showBetaMessage}} +

Welcome to {{global.appName}}

To start using {{global.appName}}, make sure to download one of the Joplin applications, either for desktop or for your mobile phone.

diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache index 51b00048e..c00e9ad99 100644 --- a/packages/server/src/views/partials/navbar.mustache +++ b/packages/server/src/views/partials/navbar.mustache @@ -18,10 +18,12 @@ Log