From a6b1cffd50b350b116044b73376109ecce8f2eac Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 20 Sep 2021 17:03:38 +0100 Subject: [PATCH] Server: Handle Joplin Cloud failed subscription payments --- .../server/src/models/SubscriptionModel.ts | 30 ++++----- packages/server/src/models/UserModel.test.ts | 41 ++++++++++-- packages/server/src/models/UserModel.ts | 65 ++++++++++++++----- .../paymentFailedAccountDisabledTemplate.ts | 19 ++++++ .../paymentFailedUploadDisabledTemplate.ts | 8 ++- 5 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 packages/server/src/views/emails/paymentFailedAccountDisabledTemplate.ts diff --git a/packages/server/src/models/SubscriptionModel.ts b/packages/server/src/models/SubscriptionModel.ts index 0002b0252..edb9a33a4 100644 --- a/packages/server/src/models/SubscriptionModel.ts +++ b/packages/server/src/models/SubscriptionModel.ts @@ -1,3 +1,4 @@ +import { Knex } from 'knex'; import { EmailSender, Subscription, User, UserFlagType, Uuid } from '../services/database/types'; import { ErrorNotFound } from '../utils/errors'; import { Day } from '../utils/time'; @@ -6,8 +7,8 @@ import paymentFailedTemplate from '../views/emails/paymentFailedTemplate'; import BaseModel from './BaseModel'; import { AccountType } from './UserModel'; -export const failedPaymentDisableUploadInterval = 7 * Day; -export const failedPaymentDisableAccount = 14 * Day; +export const failedPaymentWarningInterval = 7 * Day; +export const failedPaymentFinalAccount = 14 * Day; interface UserAndSubscription { user: User; @@ -48,24 +49,23 @@ export default class SubscriptionModel extends BaseModel { }; } - public async shouldDisableUploadSubscriptions(): Promise { - const cutOffTime = Date.now() - failedPaymentDisableUploadInterval; - - return this.db('users') + private failedPaymentSubscriptionsBaseQuery(cutOffTime: number): Knex.QueryBuilder { + const query = 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); + .where('last_payment_failed_time', '>', this.db.ref('last_payment_time')) + .where('subscriptions.is_deleted', '=', 0) + .where('last_payment_failed_time', '<', cutOffTime) + .where('users.enabled', '=', 1); + return query; } - public async shouldDisableAccountSubscriptions(): Promise { - const cutOffTime = Date.now() - failedPaymentDisableAccount; + public async failedPaymentWarningSubscriptions(): Promise { + return this.failedPaymentSubscriptionsBaseQuery(Date.now() - failedPaymentWarningInterval); + } - return this.db(this.tableName) - .where('last_payment_failed_time', '>', 'last_payment_time') - .andWhere('last_payment_failed_time', '<', cutOffTime); + public async failedPaymentFinalSubscriptions(): Promise { + return this.failedPaymentSubscriptionsBaseQuery(Date.now() - failedPaymentFinalAccount); } public async handlePayment(stripeSubscriptionId: string, success: boolean) { diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts index 473ec1d5b..319225038 100644 --- a/packages/server/src/models/UserModel.test.ts +++ b/packages/server/src/models/UserModel.test.ts @@ -3,7 +3,7 @@ import { EmailSender, User, UserFlagType } from '../services/database/types'; import { ErrorUnprocessableEntity } from '../utils/errors'; import { betaUserDateRange, stripeConfig } from '../utils/stripe'; import { accountByType, AccountType } from './UserModel'; -import { failedPaymentDisableUploadInterval } from './SubscriptionModel'; +import { failedPaymentFinalAccount, failedPaymentWarningInterval } from './SubscriptionModel'; import { stripePortalUrl } from '../utils/urlUtils'; describe('UserModel', function() { @@ -163,9 +163,10 @@ describe('UserModel', function() { const userFlag = await models().userFlag().byUserId(user1.id, UserFlagType.AccountWithoutSubscription); expect(userFlag).toBeTruthy(); + stripeConfig().enabled = false; }); - test('should disable upload and send an email if payment failed', async function() { + test('should disable upload and send an email if payment failed recently', async function() { stripeConfig().enabled = true; const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111'); @@ -174,10 +175,10 @@ describe('UserModel', function() { const sub = await models().subscription().byUserId(user1.id); const now = Date.now(); - const paymentFailedTime = now - failedPaymentDisableUploadInterval - 10; + const paymentFailedTime = now - failedPaymentWarningInterval - 10; await models().subscription().save({ id: sub.id, - last_payment_time: now - failedPaymentDisableUploadInterval * 2, + last_payment_time: now - failedPaymentWarningInterval * 2, last_payment_failed_time: paymentFailedTime, }); @@ -190,6 +191,7 @@ describe('UserModel', function() { const email = (await models().email().all()).pop(); expect(email.key).toBe(`payment_failed_upload_disabled_${paymentFailedTime}`); expect(email.body).toContain(stripePortalUrl()); + expect(email.body).toContain('14 days'); } const beforeEmailCount = (await models().email().all()).length; @@ -201,6 +203,37 @@ describe('UserModel', function() { const user2 = await models().user().loadByEmail('tutu@example.com'); expect(user2.can_upload).toBe(1); } + + stripeConfig().enabled = false; + }); + + test('should disable disable the account and send an email if payment failed for good', async function() { + stripeConfig().enabled = true; + + const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111'); + + const sub = await models().subscription().byUserId(user1.id); + + const now = Date.now(); + const paymentFailedTime = now - failedPaymentFinalAccount - 10; + await models().subscription().save({ + id: sub.id, + last_payment_time: now - failedPaymentFinalAccount * 2, + last_payment_failed_time: paymentFailedTime, + }); + + await models().user().handleFailedPaymentSubscriptions(); + + { + const user1 = await models().user().loadByEmail('toto@example.com'); + expect(user1.enabled).toBe(0); + + const email = (await models().email().all()).pop(); + expect(email.key).toBe(`payment_failed_account_disabled_${paymentFailedTime}`); + expect(email.body).toContain(stripePortalUrl()); + } + + stripeConfig().enabled = false; }); test('should send emails when the account is over the size limit', async function() { diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index c8e1b575f..1054f2107 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -1,5 +1,5 @@ import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel'; -import { EmailSender, Item, User, UserFlagType, Uuid } from '../services/database/types'; +import { EmailSender, Item, Subscription, User, UserFlagType, Uuid } from '../services/database/types'; import * as auth from '../utils/auth'; import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors'; import { ModelType } from '@joplin/lib/BaseModel'; @@ -19,6 +19,9 @@ import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUp import oversizedAccount1 from '../views/emails/oversizedAccount1'; import oversizedAccount2 from '../views/emails/oversizedAccount2'; import dayjs = require('dayjs'); +import { failedPaymentFinalAccount } from './SubscriptionModel'; +import { Day } from '../utils/time'; +import paymentFailedAccountDisabledTemplate from '../views/emails/paymentFailedAccountDisabledTemplate'; const logger = Logger.create('UserModel'); @@ -356,24 +359,54 @@ 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)); + interface SubInfo { + subs: Subscription[]; + templateFn: Function; + emailKeyPrefix: string; + flagType: UserFlagType; + } + + const subInfos: SubInfo[] = [ + { + subs: await this.models().subscription().failedPaymentWarningSubscriptions(), + emailKeyPrefix: 'payment_failed_upload_disabled_', + flagType: UserFlagType.FailedPaymentWarning, + templateFn: () => paymentFailedUploadDisabledTemplate({ disabledInDays: Math.round(failedPaymentFinalAccount / Day) }), + }, + { + subs: await this.models().subscription().failedPaymentFinalSubscriptions(), + emailKeyPrefix: 'payment_failed_account_disabled_', + flagType: UserFlagType.FailedPaymentFinal, + templateFn: () => paymentFailedAccountDisabledTemplate(), + }, + ]; + + let users: User[] = []; + for (const subInfo of subInfos) { + users = users.concat(await this.loadByIds(subInfo.subs.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; + for (const subInfo of subInfos) { + for (const sub of subInfo.subs) { + const user = users.find(u => u.id === sub.user_id); + if (!user) { + logger.error(`Could not find user for subscription ${sub.id}`); + continue; + } + + const existingFlag = await this.models().userFlag().byUserId(user.id, subInfo.flagType); + + if (!existingFlag) { + await this.models().userFlag().add(user.id, subInfo.flagType); + + await this.models().email().push({ + ...subInfo.templateFn(), + ...this.userEmailDetails(user), + key: `${subInfo.emailKeyPrefix}${sub.last_payment_failed_time}`, + }); + } } - - await this.models().userFlag().add(user.id, UserFlagType.FailedPaymentWarning); - - await this.models().email().push({ - ...paymentFailedUploadDisabledTemplate(), - ...this.userEmailDetails(user), - key: `payment_failed_upload_disabled_${sub.last_payment_failed_time}`, - }); } }, 'UserModel::handleFailedPaymentSubscriptions'); } diff --git a/packages/server/src/views/emails/paymentFailedAccountDisabledTemplate.ts b/packages/server/src/views/emails/paymentFailedAccountDisabledTemplate.ts new file mode 100644 index 000000000..f14ae1a5b --- /dev/null +++ b/packages/server/src/views/emails/paymentFailedAccountDisabledTemplate.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 disabled. + +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/emails/paymentFailedUploadDisabledTemplate.ts b/packages/server/src/views/emails/paymentFailedUploadDisabledTemplate.ts index 5918af30b..2b0ac11cb 100644 --- a/packages/server/src/views/emails/paymentFailedUploadDisabledTemplate.ts +++ b/packages/server/src/views/emails/paymentFailedUploadDisabledTemplate.ts @@ -3,13 +3,19 @@ import config from '../../config'; import { EmailSubjectBody } from '../../models/EmailModel'; import { stripePortalUrl } from '../../utils/urlUtils'; -export default (): EmailSubjectBody => { +interface Props { + disabledInDays: number; +} + +export default (props: Props): 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. +The account will be permanently disabled in ${props.disabledInDays} days. + To re-activate your account, please update your payment details, or contact us for more details. [Manage your subscription](${markdownUtils.escapeLinkUrl(stripePortalUrl())})