From 75a421edb1cc96cd293e5f07c14d678c46321bca Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Fri, 23 Jul 2021 17:03:49 +0100 Subject: [PATCH] Server: Allow admin to change Stripe subscription --- packages/server/src/routes/index/stripe.ts | 38 +++++++++---- packages/server/src/routes/index/users.ts | 19 ++++++- packages/server/src/utils/stripe.ts | 57 +++++++++++++++++++ packages/server/src/views/index/user.mustache | 30 ++++++++-- 4 files changed, 127 insertions(+), 17 deletions(-) diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index d4d6d161a..8164c784d 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -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; 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 { + 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]) { diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index f53ec0e0c..30e036967 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -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'); } diff --git a/packages/server/src/utils/stripe.ts b/packages/server/src/utils/stripe.ts index 3babd2bb7..9e8eb47da 100644 --- a/packages/server/src/utils/stripe.ts +++ b/packages/server/src/utils/stripe.ts @@ -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 }); +} diff --git a/packages/server/src/views/index/user.mustache b/packages/server/src/views/index/user.mustache index 77c0c73a0..291aad3ff 100644 --- a/packages/server/src/views/index/user.mustache +++ b/packages/server/src/views/index/user.mustache @@ -75,7 +75,8 @@

When creating a new user, if no password is specified the user will have to set it by following the link in their email.

{{/global.owner.is_admin}} -
+ +
{{#showResetPasswordButton}} @@ -83,13 +84,24 @@ {{#showDisableButton}} {{/showDisableButton}} - {{#showCancelSubscription}} - - {{/showCancelSubscription}} {{#showRestoreButton}} {{/showRestoreButton}}
+ + {{#subscription}} +
+ {{#showUpdateSubscriptionBasic}} + + {{/showUpdateSubscriptionBasic}} + {{#showUpdateSubscriptionPro}} + + {{/showUpdateSubscriptionPro}} + {{#showCancelSubscription}} + + {{/showCancelSubscription}} +
+ {{/subscription}}