1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Server: Handle Joplin Cloud failed subscription payments

This commit is contained in:
Laurent Cozic 2021-09-20 17:03:38 +01:00
parent 8cc720963a
commit a6b1cffd50
5 changed files with 127 additions and 36 deletions

View File

@ -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<Subscription> {
};
}
public async shouldDisableUploadSubscriptions(): Promise<Subscription[]> {
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<Subscription[]> {
const cutOffTime = Date.now() - failedPaymentDisableAccount;
public async failedPaymentWarningSubscriptions(): Promise<Subscription[]> {
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<Subscription[]> {
return this.failedPaymentSubscriptionsBaseQuery(Date.now() - failedPaymentFinalAccount);
}
public async handlePayment(stripeSubscriptionId: string, success: boolean) {

View File

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

View File

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

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 disabled.
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

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