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:
parent
07d2a60c75
commit
75a421edb1
@ -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]) {
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user