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:
parent
391204c31e
commit
f14c74d802
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
@ -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(),
|
||||
};
|
||||
};
|
@ -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}}
|
||||
|
Loading…
Reference in New Issue
Block a user