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:
parent
79d1ad706a
commit
5da820aa0a
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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 () => {
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user