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

Server: Allow admin to change Stripe subscription

This commit is contained in:
Laurent Cozic 2021-07-23 17:03:49 +01:00
parent 07d2a60c75
commit 75a421edb1
4 changed files with 127 additions and 17 deletions

View File

@ -9,7 +9,8 @@ import { Stripe } from 'stripe';
import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
import { initStripe, stripeConfig } from '../../utils/stripe';
import { initStripe, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
import { Subscription } from '../../db';
const logger = Logger.create('/stripe');
@ -33,12 +34,6 @@ interface CreateCheckoutSessionFields {
priceId: string;
}
function priceIdToAccountType(priceId: string): AccountType {
if (stripeConfig().basicPriceId === priceId) return AccountType.Basic;
if (stripeConfig().proPriceId === priceId) return AccountType.Pro;
throw new Error(`Unknown price ID: ${priceId}`);
}
type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise<any>;
interface PostHandlers {
@ -46,6 +41,18 @@ interface PostHandlers {
webhook: Function;
}
interface SubscriptionInfo {
sub: Subscription;
stripeSub: Stripe.Subscription;
}
async function getSubscriptionInfo(event: Stripe.Event, ctx: AppContext): Promise<SubscriptionInfo> {
const stripeSub = event.data.object as Stripe.Subscription;
const sub = await ctx.joplin.models.subscription().byStripeSubscriptionId(stripeSub.id);
if (!sub) throw new Error(`No subscription with ID: ${stripeSub.id}`);
return { sub, stripeSub };
}
export const postHandlers: PostHandlers = {
createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => {
@ -236,13 +243,24 @@ export const postHandlers: PostHandlers = {
// The subscription has been cancelled, either by us or directly
// by the user. In that case, we disable the user.
const stripeSub = event.data.object as Stripe.Subscription;
const sub = await ctx.joplin.models.subscription().byStripeSubscriptionId(stripeSub.id);
if (!sub) throw new Error(`No subscription with ID: ${stripeSub.id}`);
const { sub } = await getSubscriptionInfo(event, ctx);
await ctx.joplin.models.user().enable(sub.user_id, false);
await ctx.joplin.models.subscription().toggleSoftDelete(sub.id, true);
},
'customer.subscription.updated': async () => {
// The subscription has been updated - we apply the changes from
// Stripe to the local account.
const { sub, stripeSub } = await getSubscriptionInfo(event, ctx);
const newAccountType = priceIdToAccountType(stripeSub.items.data[0].price.id);
const user = await ctx.joplin.models.user().load(sub.user_id, { fields: ['id'] });
if (!user) throw new Error(`No such user: ${user.id}`);
logger.info(`Updating subscription of user ${user.id} to ${newAccountType}`);
await ctx.joplin.models.user().save({ id: user.id, account_type: newAccountType });
},
};
if (hooks[event.type]) {

View File

@ -10,13 +10,13 @@ import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { NotificationKey } from '../../models/NotificationModel';
import { accountTypeOptions, accountTypeToString } from '../../models/UserModel';
import { AccountType, accountTypeOptions, accountTypeToString } from '../../models/UserModel';
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 { confirmUrl } from '../../utils/urlUtils';
import { cancelSubscription } from '../../utils/stripe';
import { cancelSubscription, updateSubscriptionType } from '../../utils/stripe';
export interface CheckRepeatPasswordInput {
password: string;
@ -146,7 +146,14 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDisableButton = !isNew && !!owner.is_admin && owner.id !== user.id && user.enabled;
view.content.showCancelSubscription = !isNew && !!owner.is_admin && owner.id !== user.id && subscription;
if (subscription) {
view.content.subscription = subscription;
view.content.showCancelSubscription = !isNew && !!owner.is_admin && owner.id !== user.id;
view.content.showUpdateSubscriptionBasic = !isNew && !!owner.is_admin && user.account_type !== AccountType.Basic;
view.content.showUpdateSubscriptionPro = !isNew && !!owner.is_admin && user.account_type !== AccountType.Pro;
}
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;
@ -239,6 +246,8 @@ interface FormFields {
restore_button: string;
cancel_subscription_button: string;
send_reset_password_email: string;
update_subscription_basic_button: string;
update_subscription_pro_button: string;
}
router.post('users', async (path: SubPath, ctx: AppContext) => {
@ -275,6 +284,10 @@ router.post('users', async (path: SubPath, ctx: AppContext) => {
await userModel.sendAccountConfirmationEmail(user);
} else if (fields.cancel_subscription_button) {
await cancelSubscription(ctx.joplin.models, userId);
} else if (fields.update_subscription_basic_button) {
await updateSubscriptionType(ctx.joplin.models, userId, AccountType.Basic);
} else if (fields.update_subscription_pro_button) {
await updateSubscriptionType(ctx.joplin.models, userId, AccountType.Pro);
} else {
throw new Error('Invalid form button');
}

View File

@ -3,6 +3,7 @@ import { StripeConfig } from './types';
import { Stripe } from 'stripe';
import { Uuid } from '../db';
import { Models } from '../models/factory';
import { AccountType, accountTypeOptions } from '../models/UserModel';
const stripeLib = require('stripe');
export function stripeConfig(): StripeConfig {
@ -13,9 +14,65 @@ export function initStripe(): Stripe {
return stripeLib(stripeConfig().secretKey);
}
export function priceIdToAccountType(priceId: string): AccountType {
if (stripeConfig().basicPriceId === priceId) return AccountType.Basic;
if (stripeConfig().proPriceId === priceId) return AccountType.Pro;
throw new Error(`Unknown price ID: ${priceId}`);
}
export function accountTypeToPriceId(accountType: AccountType): string {
if (accountType === AccountType.Basic) return stripeConfig().basicPriceId;
if (accountType === AccountType.Pro) return stripeConfig().proPriceId;
throw new Error(`Unknown account type: ${accountType}`);
}
export async function cancelSubscription(models: Models, userId: Uuid) {
const sub = await models.subscription().byUserId(userId);
if (!sub) throw new Error(`No subscription for user: ${userId}`);
const stripe = initStripe();
await stripe.subscriptions.del(sub.stripe_subscription_id);
}
export async function updateSubscriptionType(models: Models, userId: Uuid, newAccountType: AccountType) {
const user = await models.user().load(userId);
if (user.account_type === newAccountType) throw new Error(`Account type is already: ${newAccountType}`);
const sub = await models.subscription().byUserId(userId);
if (!sub) throw new Error(`No subscription for user: ${userId}`);
const stripe = initStripe();
const accountTypes = accountTypeOptions();
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
const items: Stripe.SubscriptionUpdateParams.Item[] = [];
// First delete all the items that don't match the new account type. That
// means for example deleting the "Joplin Cloud Pro" item if the new account
// type is "Basic" and vice versa.
for (const t of accountTypes) {
if (!t.value) continue;
const priceId = accountTypeToPriceId(t.value);
const stripeSubItem = stripeSub.items.data.find(d => d.price.id === priceId);
if (stripeSubItem) {
if (accountTypeToPriceId(newAccountType) === priceId) throw new Error(`This account is already of type ${newAccountType}`);
items.push({
id: stripeSubItem.id,
deleted: true,
});
}
}
// Then add the item that we need, either Pro or Basic plan. It seems it's
// sufficient to specify the price ID, and from that Stripe infers the
// product.
items.push({
price: accountTypeToPriceId(newAccountType),
});
await stripe.subscriptions.update(sub.stripe_subscription_id, { items });
}

View File

@ -75,7 +75,8 @@
<p class="help">When creating a new user, if no password is specified the user will have to set it by following the link in their email.</p>
{{/global.owner.is_admin}}
</div>
<div class="control">
<div class="control block">
<input type="submit" name="post_button" class="button is-primary" value="{{buttonTitle}}" />
{{#showResetPasswordButton}}
<input type="submit" name="send_reset_password_email" class="button is-warning" value="Send reset password email" />
@ -83,13 +84,24 @@
{{#showDisableButton}}
<input type="submit" name="disable_button" class="button is-danger" value="Disable" />
{{/showDisableButton}}
{{#showCancelSubscription}}
<input type="submit" name="cancel_subscription_button" class="button is-danger" value="Cancel subscription" />
{{/showCancelSubscription}}
{{#showRestoreButton}}
<input type="submit" name="restore_button" class="button is-danger" value="Restore" />
{{/showRestoreButton}}
</div>
{{#subscription}}
<div class="control block">
{{#showUpdateSubscriptionBasic}}
<input type="submit" name="update_subscription_basic_button" class="button is-warning" value="Downgrade to Basic" />
{{/showUpdateSubscriptionBasic}}
{{#showUpdateSubscriptionPro}}
<input type="submit" name="update_subscription_pro_button" class="button is-warning" value="Upgrade to Pro" />
{{/showUpdateSubscriptionPro}}
{{#showCancelSubscription}}
<input type="submit" name="cancel_subscription_button" class="button is-danger" value="Cancel subscription" />
{{/showCancelSubscription}}
</div>
{{/subscription}}
</form>
<script>
@ -109,6 +121,16 @@
const ok = confirm('Cancel this subscription?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_basic_button') {
const ok = confirm('Downgrade to Basic subscription?');
if (!ok) event.preventDefault();
}
if (event.submitter.getAttribute('name') === 'update_subscription_pro_button') {
const ok = confirm('Upgrade to Pro subscription?');
if (!ok) event.preventDefault();
}
});
setupPasswordStrengthHandler();