diff --git a/packages/server/src/models/SubscriptionModel.ts b/packages/server/src/models/SubscriptionModel.ts index f7c0943f74..23964acde7 100644 --- a/packages/server/src/models/SubscriptionModel.ts +++ b/packages/server/src/models/SubscriptionModel.ts @@ -1,10 +1,29 @@ -import { EmailSender, Subscription, Uuid } from '../db'; +import { EmailSender, Subscription, User, Uuid } from '../db'; import { ErrorNotFound } from '../utils/errors'; +import { Day } from '../utils/time'; import uuidgen from '../utils/uuidgen'; import paymentFailedTemplate from '../views/emails/paymentFailedTemplate'; import BaseModel from './BaseModel'; import { AccountType } from './UserModel'; +export const failedPaymentDisableUploadInterval = 7 * Day; +export const failedPaymentDisableAccount = 14 * Day; + +interface UserAndSubscription { + user: User; + subscription: Subscription; +} + +enum PaymentAttemptStatus { + Success = 'Success', + Failed = 'Failed', +} + +interface PaymentAttempt { + status: PaymentAttemptStatus; + time: number; +} + export default class SubscriptionModel extends BaseModel { public get tableName(): string { @@ -15,9 +34,43 @@ export default class SubscriptionModel extends BaseModel { return false; } - public async handlePayment(subscriptionId: string, success: boolean) { - const sub = await this.byStripeSubscriptionId(subscriptionId); - if (!sub) throw new ErrorNotFound(`No such subscription: ${subscriptionId}`); + public lastPaymentAttempt(sub: Subscription): PaymentAttempt { + if (sub.last_payment_failed_time > sub.last_payment_time) { + return { + status: PaymentAttemptStatus.Failed, + time: sub.last_payment_failed_time, + }; + } + + return { + status: PaymentAttemptStatus.Success, + time: sub.last_payment_time, + }; + } + + public async shouldDisableUploadSubscriptions(): Promise { + const cutOffTime = Date.now() - failedPaymentDisableUploadInterval; + + return this.db('users') + .leftJoin('subscriptions', 'users.id', 'subscriptions.user_id') + .select('subscriptions.id', 'subscriptions.user_id', 'last_payment_failed_time') + .where('users.can_upload', '=', 1) + .andWhere('last_payment_failed_time', '>', this.db.ref('last_payment_time')) + .andWhere('subscriptions.is_deleted', '=', 0) + .andWhere('last_payment_failed_time', '<', cutOffTime); + } + + public async shouldDisableAccountSubscriptions(): Promise { + const cutOffTime = Date.now() - failedPaymentDisableAccount; + + return this.db(this.tableName) + .where('last_payment_failed_time', '>', 'last_payment_time') + .andWhere('last_payment_failed_time', '<', cutOffTime); + } + + public async handlePayment(stripeSubscriptionId: string, success: boolean) { + const sub = await this.byStripeSubscriptionId(stripeSubscriptionId); + if (!sub) throw new ErrorNotFound(`No such subscription: ${stripeSubscriptionId}`); const now = Date.now(); @@ -25,21 +78,28 @@ export default class SubscriptionModel extends BaseModel { if (success) { toSave.last_payment_time = now; + toSave.last_payment_failed_time = 0; + await this.save(toSave); } else { - toSave.last_payment_failed_time = now; + // We only update the payment failed time if it's not already set + // since the only thing that matter is the first time the payment + // failed. + if (!sub.last_payment_failed_time) { + toSave.last_payment_failed_time = now; - const user = await this.models().user().load(sub.user_id, { fields: ['email', 'id', 'full_name'] }); + const user = await this.models().user().load(sub.user_id, { fields: ['email', 'id', 'full_name'] }); - await this.models().email().push({ - ...paymentFailedTemplate(), - recipient_email: user.email, - recipient_id: user.id, - recipient_name: user.full_name || '', - sender_id: EmailSender.Support, - }); + await this.models().email().push({ + ...paymentFailedTemplate(), + recipient_email: user.email, + recipient_id: user.id, + recipient_name: user.full_name || '', + sender_id: EmailSender.Support, + }); + + await this.save(toSave); + } } - - await this.save(toSave); } public async byStripeSubscriptionId(id: string): Promise { @@ -51,7 +111,7 @@ export default class SubscriptionModel extends BaseModel { } public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) { - return this.withTransaction(async () => { + return this.withTransaction(async () => { const user = await this.models().user().save({ account_type: accountType, email, diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts index ad59623c62..4464d2eaca 100644 --- a/packages/server/src/models/UserModel.test.ts +++ b/packages/server/src/models/UserModel.test.ts @@ -3,6 +3,8 @@ import { EmailSender, User } from '../db'; import { ErrorUnprocessableEntity } from '../utils/errors'; import { betaUserDateRange, stripeConfig } from '../utils/stripe'; import { AccountType } from './UserModel'; +import { failedPaymentDisableUploadInterval } from './SubscriptionModel'; +import { stripePortalUrl } from '../utils/urlUtils'; describe('UserModel', function() { @@ -160,4 +162,42 @@ describe('UserModel', function() { expect(reloadedUser.can_upload).toBe(0); }); + test('should disable upload and send an email if payment failed', async function() { + stripeConfig().enabled = true; + + const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111'); + await models().subscription().saveUserAndSubscription('tutu@example.com', 'Tutu', AccountType.Basic, 'usr_222', 'sub_222'); + + const sub = await models().subscription().byUserId(user1.id); + + const now = Date.now(); + const paymentFailedTime = now - failedPaymentDisableUploadInterval - 10; + await models().subscription().save({ + id: sub.id, + last_payment_time: now - failedPaymentDisableUploadInterval * 2, + last_payment_failed_time: paymentFailedTime, + }); + + await models().user().handleFailedPaymentSubscriptions(); + + { + const user1 = await models().user().loadByEmail('toto@example.com'); + expect(user1.can_upload).toBe(0); + + const email = (await models().email().all()).pop(); + expect(email.key).toBe(`payment_failed_upload_disabled_${paymentFailedTime}`); + expect(email.body).toContain(stripePortalUrl()); + } + + const beforeEmailCount = (await models().email().all()).length; + await models().user().handleFailedPaymentSubscriptions(); + const afterEmailCount = (await models().email().all()).length; + expect(beforeEmailCount).toBe(afterEmailCount); + + { + const user2 = await models().user().loadByEmail('tutu@example.com'); + expect(user2.can_upload).toBe(1); + } + }); + }); diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index d9d5af6fa7..6fed974e32 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -14,6 +14,10 @@ import accountConfirmationTemplate from '../views/emails/accountConfirmationTemp import resetPasswordTemplate from '../views/emails/resetPasswordTemplate'; import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe'; import endOfBetaTemplate from '../views/emails/endOfBetaTemplate'; +import Logger from '@joplin/lib/Logger'; +import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate'; + +const logger = Logger.create('UserModel'); interface UserEmailDetails { sender_id: EmailSender; @@ -121,6 +125,7 @@ export default class UserModel extends BaseModel { if ('max_item_size' in object) user.max_item_size = object.max_item_size; if ('max_total_item_size' in object) user.max_total_item_size = object.max_total_item_size; if ('can_share_folder' in object) user.can_share_folder = object.can_share_folder; + if ('can_upload' in object) user.can_upload = object.can_upload; if ('account_type' in object) user.account_type = object.account_type; if ('must_set_password' in object) user.must_set_password = object.must_set_password; @@ -309,6 +314,10 @@ export default class UserModel extends BaseModel { await this.models().token().deleteByValue(user.id, token); } + // public async disableUnpaidAccounts() { + + // } + public async handleBetaUserEmails() { if (!stripeConfig().enabled) return; @@ -351,6 +360,29 @@ export default class UserModel extends BaseModel { } } + public async handleFailedPaymentSubscriptions() { + const subscriptions = await this.models().subscription().shouldDisableUploadSubscriptions(); + const users = await this.loadByIds(subscriptions.map(s => s.user_id)); + + await this.withTransaction(async () => { + for (const sub of subscriptions) { + const user = users.find(u => u.id === sub.user_id); + if (!user) { + logger.error(`Could not find user for subscription ${sub.id}`); + continue; + } + + await this.save({ id: user.id, can_upload: 0 }); + + await this.models().email().push({ + ...paymentFailedUploadDisabledTemplate(), + ...this.userEmailDetails(user), + key: `payment_failed_upload_disabled_${sub.last_payment_failed_time}`, + }); + } + }); + } + 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/users.ts b/packages/server/src/routes/index/users.ts index 718241b9a2..6dfdd65dde 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -14,10 +14,11 @@ import { AccountType, accountTypeOptions, accountTypeToString } from '../../mode import uuidgen from '../../utils/uuidgen'; import { formatMaxItemSize, formatMaxTotalSize, formatTotalSize, formatTotalSizePercent, yesOrNo } from '../../utils/strings'; import { getCanShareFolder, totalSizeClass } from '../../models/utils/user'; -import { yesNoDefaultOptions } from '../../utils/views/select'; +import { yesNoDefaultOptions, yesNoOptions } from '../../utils/views/select'; import { confirmUrl } from '../../utils/urlUtils'; import { cancelSubscriptionByUserId, updateSubscriptionType } from '../../utils/stripe'; import { createCsrfTag } from '../../utils/csrf'; +import { formatDateTime } from '../../utils/time'; export interface CheckRepeatPasswordInput { password: string; @@ -58,6 +59,7 @@ function makeUser(isNew: boolean, fields: any): User { if ('max_item_size' in fields) user.max_item_size = intOrDefaultToValue(fields, 'max_item_size'); if ('max_total_item_size' in fields) user.max_total_item_size = intOrDefaultToValue(fields, 'max_total_item_size'); if ('can_share_folder' in fields) user.can_share_folder = boolOrDefaultToValue(fields, 'can_share_folder'); + if ('can_upload' in fields) user.can_upload = intOrDefaultToValue(fields, 'can_upload'); if ('account_type' in fields) user.account_type = Number(fields.account_type); const password = checkRepeatPassword(fields, false); @@ -120,13 +122,13 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null const owner = ctx.joplin.owner; const isMe = userIsMe(path); const isNew = userIsNew(path); - const userModel = ctx.joplin.models.user(); + const models = ctx.joplin.models; const userId = userIsMe(path) ? owner.id : path.id; - user = !isNew ? user || await userModel.load(userId) : null; + user = !isNew ? user || await models.user().load(userId) : null; if (isNew && !user) user = defaultUser(); - await userModel.checkIfAllowed(ctx.joplin.owner, AclAction.Read, user); + await models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Read, user); let postUrl = ''; @@ -150,16 +152,21 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null view.content.csrfTag = await createCsrfTag(ctx); if (subscription) { + const lastPaymentAttempt = models.subscription().lastPaymentAttempt(subscription); + view.content.subscription = subscription; view.content.showCancelSubscription = !isNew; view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic; view.content.showUpdateSubscriptionPro = !isNew && user.account_type !== AccountType.Pro; + view.content.subLastPaymentStatus = lastPaymentAttempt.status; + view.content.subLastPaymentDate = formatDateTime(lastPaymentAttempt.time); } view.content.showRestoreButton = !isNew && !!owner.is_admin && !user.enabled; view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled; view.content.canSetEmail = isNew || owner.is_admin; view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder'); + view.content.canUploadOptions = yesNoOptions(user, 'can_upload'); view.jsFiles.push('zxcvbn'); view.cssFiles.push('index/user'); diff --git a/packages/server/src/services/CronService.ts b/packages/server/src/services/CronService.ts index c1cf087d7e..70254e5849 100644 --- a/packages/server/src/services/CronService.ts +++ b/packages/server/src/services/CronService.ts @@ -29,6 +29,10 @@ export default class CronService extends BaseService { cron.schedule('0 12 * * *', async () => { await runCronTask('handleBetaUserEmails', async () => this.models.user().handleBetaUserEmails()); }); + + cron.schedule('0 13 * * *', async () => { + await runCronTask('handleFailedPaymentSubscriptions', async () => this.models.user().handleFailedPaymentSubscriptions()); + }); } } diff --git a/packages/server/src/tools/debugTools.ts b/packages/server/src/tools/debugTools.ts index 7a2c030905..7664518024 100644 --- a/packages/server/src/tools/debugTools.ts +++ b/packages/server/src/tools/debugTools.ts @@ -1,5 +1,6 @@ import { DbConnection, dropTables, migrateDb } from '../db'; import newModelFactory from '../models/factory'; +import { AccountType } from '../models/UserModel'; import { Config } from '../utils/types'; export async function handleDebugCommands(argv: any, db: DbConnection, config: Config): Promise { @@ -16,13 +17,38 @@ export async function createTestUsers(db: DbConnection, config: Config) { await dropTables(db); await migrateDb(db); + const password = 'hunter1hunter2hunter3'; const models = newModelFactory(db, config); for (let userNum = 1; userNum <= 2; userNum++) { await models.user().save({ email: `user${userNum}@example.com`, - password: 'hunter1hunter2hunter3', + password, full_name: `User ${userNum}`, }); } + + { + const { user } = await models.subscription().saveUserAndSubscription( + 'usersub@example.com', + 'With Sub', + AccountType.Basic, + 'usr_111', + 'sub_111' + ); + await models.user().save({ id: user.id, password }); + } + + { + const { user, subscription } = await models.subscription().saveUserAndSubscription( + 'userfailedpayment@example.com', + 'Failed Payment', + AccountType.Basic, + 'usr_222', + 'sub_222' + ); + await models.user().save({ id: user.id, password }); + await models.subscription().handlePayment(subscription.stripe_subscription_id, false); + } + } diff --git a/packages/server/src/utils/time.ts b/packages/server/src/utils/time.ts index 89718f6ccc..3dc045bd9a 100644 --- a/packages/server/src/utils/time.ts +++ b/packages/server/src/utils/time.ts @@ -1,4 +1,25 @@ import dayjs = require('dayjs'); +import utc = require('dayjs/plugin/utc'); +import timezone = require('dayjs/plugin/timezone'); + +function defaultTimezone() { + return dayjs.tz.guess(); +} + +function initDayJs() { + dayjs.extend(utc); + dayjs.extend(timezone); + dayjs.tz.setDefault(defaultTimezone()); +} + +initDayJs(); + +export const Second = 60 * 1000; +export const Minute = 60 * Second; +export const Hour = 60 * Minute; +export const Day = 24 * Hour; +export const Week = 7 * Day; +export const Month = 30 * Day; export function msleep(ms: number) { return new Promise((resolve: Function) => { @@ -9,11 +30,9 @@ export function msleep(ms: number) { } export function formatDateTime(ms: number): string { - return dayjs(ms).format('D MMM YY HH:mm:ss'); + return `${dayjs(ms).format('D MMM YY HH:mm:ss')} (${defaultTimezone()})`; } - - // Use the utility functions below to easily measure performance of a block or // line of code. interface PerfTimer { diff --git a/packages/server/src/utils/views/select.ts b/packages/server/src/utils/views/select.ts index b23f65b7e6..27a8541c84 100644 --- a/packages/server/src/utils/views/select.ts +++ b/packages/server/src/utils/views/select.ts @@ -34,3 +34,10 @@ export function yesNoDefaultOptions(object: any, key: string): Option[] { selectOption('No', '0', object[key] === 0), ]; } + +export function yesNoOptions(object: any, key: string): Option[] { + return [ + selectOption('Yes', '1', object[key] === 1), + selectOption('No', '0', object[key] === 0), + ]; +} diff --git a/packages/server/src/views/emails/paymentFailedUploadDisabledTemplate.ts b/packages/server/src/views/emails/paymentFailedUploadDisabledTemplate.ts new file mode 100644 index 0000000000..5918af30b0 --- /dev/null +++ b/packages/server/src/views/emails/paymentFailedUploadDisabledTemplate.ts @@ -0,0 +1,19 @@ +import markdownUtils from '@joplin/lib/markdownUtils'; +import config from '../../config'; +import { EmailSubjectBody } from '../../models/EmailModel'; +import { stripePortalUrl } from '../../utils/urlUtils'; + +export default (): EmailSubjectBody => { + return { + subject: `Your ${config().appName} payment could not be processed`, + body: ` + +Your last ${config().appName} payment could not be processed. As a result your account has been temporarily restricted: it is no longer possible to upload data to it. + +To re-activate your account, please update your payment details, or contact us for more details. + +[Manage your subscription](${markdownUtils.escapeLinkUrl(stripePortalUrl())}) + +`.trim(), + }; +}; diff --git a/packages/server/src/views/index/user.mustache b/packages/server/src/views/index/user.mustache index cc3ac2f2b7..3fc64f5b8f 100644 --- a/packages/server/src/views/index/user.mustache +++ b/packages/server/src/views/index/user.mustache @@ -62,6 +62,17 @@ + +
+ +
+ +
+
{{/global.owner.is_admin}}
@@ -102,6 +113,7 @@ {{#global.owner.is_admin}}

Stripe Subscription ID: {{subscription.stripe_subscription_id}}

+

Last payment status: {{subLastPaymentStatus}} on {{subLastPaymentDate}}

{{#showUpdateSubscriptionBasic}} {{/showUpdateSubscriptionBasic}}