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

Server: Disable upload for accounts with subscription failed payments

This commit is contained in:
Laurent Cozic 2021-08-09 19:00:22 +01:00
parent 391204c31e
commit f14c74d802
10 changed files with 250 additions and 24 deletions

View File

@ -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<Subscription> {
public get tableName(): string {
@ -15,9 +34,43 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
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<Subscription[]> {
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<Subscription[]> {
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<Subscription> {
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<Subscription> {
@ -51,7 +111,7 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
}
public async saveUserAndSubscription(email: string, fullName: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
return this.withTransaction(async () => {
return this.withTransaction<UserAndSubscription>(async () => {
const user = await this.models().user().save({
account_type: accountType,
email,

View File

@ -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);
}
});
});

View File

@ -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<User> {
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<User> {
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<User> {
}
}
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();

View File

@ -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');

View File

@ -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());
});
}
}

View File

@ -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<boolean> {
@ -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);
}
}

View File

@ -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 {

View File

@ -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),
];
}

View File

@ -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(),
};
};

View File

@ -62,6 +62,17 @@
</select>
</div>
</div>
<div class="field">
<label class="label">Can upload</label>
<div class="select">
<select name="can_upload">
{{#canUploadOptions}}
<option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
{{/canUploadOptions}}
</select>
</div>
</div>
{{/global.owner.is_admin}}
<div class="field">
@ -102,6 +113,7 @@
{{#global.owner.is_admin}}
<div class="control block">
<p class="block">Stripe Subscription ID: <a href="https://dashboard.stripe.com/subscriptions/{{subscription.stripe_subscription_id}}">{{subscription.stripe_subscription_id}}</a></p>
<p class="block">Last payment status: <strong>{{subLastPaymentStatus}}</strong> on <strong>{{subLastPaymentDate}}</strong></p>
{{#showUpdateSubscriptionBasic}}
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
{{/showUpdateSubscriptionBasic}}