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

Server: Correctly attach Stripe sub to Joplin Server sub when it is recreated from Stripe

This commit is contained in:
Laurent Cozic 2021-09-28 15:09:21 +01:00
parent 79d1ad706a
commit 5da820aa0a
5 changed files with 198 additions and 105 deletions

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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();

View File

@ -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);
}
}
}

View File

@ -140,16 +140,16 @@
</div>
{{/subscription}}
{{#userFlags}}
<h1 class="title">Flags</h1>
{{/userFlags}}
{{#userFlags}}
<ul>
<li>{{.}}</li>
</ul>
{{/userFlags}}
{{#hasFlags}}
<div class="content">
<h1 class="title">Flags</h1>
{{#userFlags}}
<ul>
<li>{{.}}</li>
</ul>
{{/userFlags}}
</div>
{{/hasFlags}}
</form>