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