Flags
+ {{#userFlags}} +-
+
- {{.}} +
diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts index c646f9292e..718ef7588a 100644 --- a/packages/server/src/routes/index/stripe.test.ts +++ b/packages/server/src/routes/index/stripe.test.ts @@ -7,7 +7,7 @@ import { AppContext } from '../../utils/types'; import uuidgen from '../../utils/uuidgen'; import { postHandlers } from './stripe'; -function mockStripe() { +function mockStripe(overrides: any = null) { return { customers: { retrieve: jest.fn(), @@ -15,6 +15,7 @@ function mockStripe() { subscriptions: { del: jest.fn(), }, + ...overrides, }; } @@ -22,6 +23,7 @@ interface WebhookOptions { stripe?: any; eventId?: string; subscriptionId?: string; + customerId?: string; sessionId?: string; } @@ -44,6 +46,7 @@ async function simulateWebhook(ctx: AppContext, type: string, object: any, optio async function createUserViaSubscription(ctx: AppContext, userEmail: string, options: WebhookOptions = {}) { options = { subscriptionId: `sub_${uuidgen()}`, + customerId: `cus_${uuidgen()}`, ...options, }; @@ -53,7 +56,7 @@ async function createUserViaSubscription(ctx: AppContext, userEmail: string, opt await simulateWebhook(ctx, 'checkout.session.completed', { id: stripeSessionId, - customer: `cus_${uuidgen()}`, + customer: options.customerId, subscription: options.subscriptionId, customer_details: { email: userEmail, @@ -214,4 +217,92 @@ describe('index/stripe', function() { expect(user.can_upload).toBe(1); }); + test('should attach new sub to existing user', async function() { + // Simulates: + // - User subscribes + // - Later the subscription is cancelled, either automatically by Stripe or manually + // - Then a new subscription is attached to the user on Stripe + // => In that case, the sub should be attached to the user on Joplin Server + + const stripe = mockStripe({ + customers: { + retrieve: async () => { + return { + name: 'Toto', + email: 'toto@example.com', + }; + }, + }, + }); + + const ctx = await koaAppContext(); + + await createUserViaSubscription(ctx, 'toto@example.com', { + stripe, + subscriptionId: 'sub_1', + customerId: 'cus_toto', + }); + await simulateWebhook(ctx, 'customer.subscription.deleted', { id: 'sub_1' }); + + const stripePrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly }); + + await simulateWebhook(ctx, 'customer.subscription.created', { + id: 'sub_new', + customer: 'cus_toto', + items: { data: [{ price: { id: stripePrice.id } }] }, + }, { stripe }); + + const user = (await models().user().all())[0]; + const sub = await models().subscription().byUserId(user.id); + + expect(sub.stripe_user_id).toBe('cus_toto'); + expect(sub.stripe_subscription_id).toBe('sub_new'); + }); + + test('should not cancel a subscription as duplicate if it is already associated with a user', async function() { + // When user goes through a Stripe checkout, we get the following + // events: + // + // - checkout.session.completed + // - customer.subscription.created + // + // However we create the subscription as soon as we get + // "checkout.session.completed", because by then we already have all the + // necessary information. The problem is that Stripe is then going to + // send "customer.subscription.created", even though the sub is already + // created. Also we have some code to cancel duplicate subscriptions + // (when a user accidentally subscribe multiple times), and we don't + // want that newly, valid, subscription to be cancelled as a duplicate. + + const stripe = mockStripe({ + customers: { + retrieve: async () => { + return { + name: 'Toto', + email: 'toto@example.com', + }; + }, + }, + }); + + const ctx = await koaAppContext(); + + await createUserViaSubscription(ctx, 'toto@example.com', { + stripe, + subscriptionId: 'sub_1', + customerId: 'cus_toto', + }); + + const stripePrice = findPrice(stripeConfig().prices, { accountType: 1, period: PricePeriod.Monthly }); + + await simulateWebhook(ctx, 'customer.subscription.created', { + id: 'sub_1', + customer: 'cus_toto', + items: { data: [{ price: { id: stripePrice.id } }] }, + }, { stripe }); + + // Verify that we didn't try to delete that new subscription + expect(stripe.subscriptions.del).toHaveBeenCalledTimes(0); + }); + }); diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index 1d357e70c1..5514066126 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -12,6 +12,7 @@ import { AccountType } from '../../models/UserModel'; import { betaUserTrialPeriodDays, cancelSubscription, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe'; import { Subscription, UserFlagType } from '../../services/database/types'; import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud'; +import { Models } from '../../models/factory'; const logger = Logger.create('/stripe'); @@ -56,6 +57,64 @@ async function getSubscriptionInfo(event: Stripe.Event, ctx: AppContext): Promis return { sub, stripeSub }; } +export const handleSubscriptionCreated = async (stripe: Stripe, models: Models, customerName: string, userEmail: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) => { + const existingUser = await models.user().loadByEmail(userEmail); + + if (existingUser) { + const sub = await models.subscription().byUserId(existingUser.id); + + if (!sub) { + logger.info(`Setting up subscription for existing user: ${existingUser.email}`); + + // First set the account type correctly (in case the + // user also upgraded or downgraded their account). + await models.user().save({ + id: existingUser.id, + account_type: accountType, + }); + + // Also clear any payment and subscription related flags + // since if we're here it means payment was successful + await models.userFlag().removeMulti(existingUser.id, [ + UserFlagType.FailedPaymentWarning, + UserFlagType.FailedPaymentFinal, + UserFlagType.SubscriptionCancelled, + UserFlagType.AccountWithoutSubscription, + ]); + + // Then save the subscription + await models.subscription().save({ + user_id: existingUser.id, + stripe_user_id: stripeUserId, + stripe_subscription_id: stripeSubscriptionId, + last_payment_time: Date.now(), + }); + } else { + if (sub.stripe_subscription_id === stripeSubscriptionId) { + // Stripe probably dispatched a "customer.subscription.created" + // event after "checkout.session.completed", so we already have + // save the subscription and can skip processing. + } else { + // The user already has a subscription. Most likely + // they accidentally created a second one, so cancel + // it. + logger.info(`User ${existingUser.email} already has a subscription: ${sub.stripe_subscription_id} - cancelling duplicate`); + await cancelSubscription(stripe, stripeSubscriptionId); + } + } + } else { + logger.info(`Creating subscription for new user: ${userEmail}`); + + await models.subscription().saveUserAndSubscription( + userEmail, + customerName, + accountType, + stripeUserId, + stripeSubscriptionId + ); + } +}; + export const postHandlers: PostHandlers = { createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => { @@ -172,51 +231,6 @@ export const postHandlers: PostHandlers = { // For testing: `stripe trigger checkout.session.completed` // Or use /checkoutTest URL. - // { - // "object": { - // "id": "cs_test_xxxxxxxxxxxxxxxxxx", - // "object": "checkout.session", - // "allow_promotion_codes": null, - // "amount_subtotal": 499, - // "amount_total": 499, - // "billing_address_collection": null, - // "cancel_url": "http://joplincloud.local:22300/stripe/cancel", - // "client_reference_id": null, - // "currency": "gbp", - // "customer": "cus_xxxxxxxxxxxx", - // "customer_details": { - // "email": "toto@example.com", - // "tax_exempt": "none", - // "tax_ids": [ - // ] - // }, - // "customer_email": null, - // "livemode": false, - // "locale": null, - // "metadata": { - // }, - // "mode": "subscription", - // "payment_intent": null, - // "payment_method_options": { - // }, - // "payment_method_types": [ - // "card" - // ], - // "payment_status": "paid", - // "setup_intent": null, - // "shipping": null, - // "shipping_address_collection": null, - // "submit_type": null, - // "subscription": "sub_xxxxxxxxxxxxxxxx", - // "success_url": "http://joplincloud.local:22300/stripe/success?session_id={CHECKOUT_SESSION_ID}", - // "total_details": { - // "amount_discount": 0, - // "amount_shipping": 0, - // "amount_tax": 0 - // } - // } - // } - const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session; const userEmail = checkoutSession.customer_details.email || checkoutSession.customer_email; @@ -252,55 +266,41 @@ export const postHandlers: PostHandlers = { const stripeUserId = checkoutSession.customer as string; const stripeSubscriptionId = checkoutSession.subscription as string; - const existingUser = await models.user().loadByEmail(userEmail); + await handleSubscriptionCreated( + stripe, + models, + customerName, + userEmail, + accountType, + stripeUserId, + stripeSubscriptionId + ); + }, - if (existingUser) { - const sub = await models.subscription().byUserId(existingUser.id); + 'customer.subscription.created': async () => { + const stripeSub: Stripe.Subscription = event.data.object as Stripe.Subscription; + const stripeUserId = stripeSub.customer as string; + const stripeSubscriptionId = stripeSub.id; + const customer = await stripe.customers.retrieve(stripeUserId) as Stripe.Customer; - if (!sub) { - logger.info(`Setting up subscription for existing user: ${existingUser.email}`); - - // First set the account type correctly (in case the - // user also upgraded or downgraded their account). - await models.user().save({ - id: existingUser.id, - account_type: accountType, - }); - - // Also clear any payment and subscription related flags - // since if we're here it means payment was successful - await models.userFlag().removeMulti(existingUser.id, [ - UserFlagType.FailedPaymentWarning, - UserFlagType.FailedPaymentFinal, - UserFlagType.SubscriptionCancelled, - UserFlagType.AccountWithoutSubscription, - ]); - - // Then save the subscription - await models.subscription().save({ - user_id: existingUser.id, - stripe_user_id: stripeUserId, - stripe_subscription_id: stripeSubscriptionId, - last_payment_time: Date.now(), - }); - } else { - // The user already has a subscription. Most likely - // they accidentally created a second one, so cancel - // it. - logger.info(`User ${existingUser.email} already has a subscription: ${sub.stripe_subscription_id} - cancelling duplicate`); - await cancelSubscription(stripe, stripeSubscriptionId); - } - } else { - logger.info(`Creating subscription for new user: ${userEmail}`); - - await models.subscription().saveUserAndSubscription( - userEmail, - customerName, - accountType, - stripeUserId, - stripeSubscriptionId - ); + let accountType = AccountType.Basic; + try { + // Really have to dig out the price ID + const priceId = stripeSub.items.data[0].price.id; + accountType = priceIdToAccountType(priceId); + } catch (error) { + logger.error('Could not determine account type from price ID - defaulting to "Basic"', error); } + + await handleSubscriptionCreated( + stripe, + models, + customer.name, + customer.email, + accountType, + stripeUserId, + stripeSubscriptionId + ); }, 'invoice.paid': async () => { diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index c85e62b667..eb73a182a1 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -146,11 +146,11 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null postUrl = `${config().baseUrl}/users/${user.id}`; } - let userFlags: string[] = isNew ? null : (await models.userFlag().allByUserId(user.id)).map(f => { + let userFlags: string[] = isNew ? [] : (await models.userFlag().allByUserId(user.id)).map(f => { return userFlagToString(f); }); - if (!userFlags || !userFlags.length || !owner.is_admin) userFlags = null; + if (!owner.is_admin) userFlags = []; const subscription = !isNew ? await ctx.joplin.models.subscription().byUserId(userId) : null; @@ -179,6 +179,7 @@ router.get('users/:id', async (path: SubPath, ctx: AppContext, user: User = null view.content.showResetPasswordButton = !isNew && owner.is_admin && user.enabled; view.content.canShareFolderOptions = yesNoDefaultOptions(user, 'can_share_folder'); view.content.canUploadOptions = yesNoOptions(user, 'can_upload'); + view.content.hasFlags = !!userFlags.length; view.content.userFlags = userFlags; view.content.stripePortalUrl = stripePortalUrl(); diff --git a/packages/server/src/tools/debugTools.ts b/packages/server/src/tools/debugTools.ts index b2de24b27f..c0c7855aeb 100644 --- a/packages/server/src/tools/debugTools.ts +++ b/packages/server/src/tools/debugTools.ts @@ -93,6 +93,7 @@ export async function createTestUsers(db: DbConnection, config: Config, options: }); await models.userFlag().add(user.id, UserFlagType.AccountOverLimit); + await models.userFlag().add(user.id, UserFlagType.FailedPaymentWarning); } } } diff --git a/packages/server/src/views/index/user.mustache b/packages/server/src/views/index/user.mustache index d3435aff24..bcf88b5e7d 100644 --- a/packages/server/src/views/index/user.mustache +++ b/packages/server/src/views/index/user.mustache @@ -140,16 +140,16 @@ {{/subscription}} - {{#userFlags}} -