mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-29 12:01:32 +02:00
Merge pull request #27999 from overleaf/em-reactivate-add-on
Add option to reactivate an add-on GitOrigin-RevId: a1795f37dac5141996d626d87ba3a9bae1d218dd
This commit is contained in:
@@ -271,6 +271,36 @@ class PaymentProviderSubscription {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate an add-on on this subscription
|
||||
*
|
||||
* @param {string} code - add-on code
|
||||
* @return {PaymentProviderSubscriptionChangeRequest}
|
||||
*
|
||||
* @throws {AddOnNotPresentError} if the add-on is not pending cancellation
|
||||
*/
|
||||
getRequestForAddOnReactivation(code) {
|
||||
const reactivatedAddOn = this.addOns.find(addOn => addOn.code === code)
|
||||
const pendingChange = this.pendingChange
|
||||
if (reactivatedAddOn == null || pendingChange == null) {
|
||||
throw new AddOnNotPresentError('Add-on is not pending cancellation', {
|
||||
subscriptionId: this.id,
|
||||
addOnCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
const addOnUpdates = pendingChange.nextAddOns
|
||||
.filter(addOn => addOn.code !== code)
|
||||
.map(addOn => addOn.toAddOnUpdate())
|
||||
addOnUpdates.push(reactivatedAddOn.toAddOnUpdate())
|
||||
|
||||
return new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: this,
|
||||
timeframe: 'term_end',
|
||||
addOnUpdates,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Form a request to revert the plan to it's last saved backup state
|
||||
*
|
||||
|
||||
@@ -690,6 +690,43 @@ async function removeAddon(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
const reactivateAddonSchema = z.object({
|
||||
params: z.object({
|
||||
addOnCode: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Reactivate an add-on pending cancellation
|
||||
*
|
||||
* This "cancels" the cancellation.
|
||||
*/
|
||||
async function reactivateAddon(req, res) {
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
const { params } = validateReq(req, reactivateAddonSchema)
|
||||
const addOnCode = params.addOnCode
|
||||
|
||||
if (addOnCode !== AI_ADD_ON_CODE) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
try {
|
||||
await SubscriptionHandler.promises.reactivateAddon(user._id, addOnCode)
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
if (err instanceof AddOnNotPresentError) {
|
||||
HttpErrorHandler.badRequest(
|
||||
req,
|
||||
res,
|
||||
'The requested add-on is not pending cancellation',
|
||||
{ addon: addOnCode }
|
||||
)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSubscription(req, res, next) {
|
||||
const planCode = req.query.planCode
|
||||
if (!planCode) {
|
||||
@@ -1071,6 +1108,7 @@ module.exports = {
|
||||
previewAddonPurchase: expressify(previewAddonPurchase),
|
||||
purchaseAddon,
|
||||
removeAddon,
|
||||
reactivateAddon,
|
||||
makeChangePreview,
|
||||
getRecommendedCurrency,
|
||||
getLatamCountryBannerDetails,
|
||||
|
||||
@@ -285,6 +285,16 @@ async function removeAddon(userId, addOnCode) {
|
||||
await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivates an add-on pending cancellation
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} addOnCode
|
||||
*/
|
||||
async function reactivateAddon(userId, addOnCode) {
|
||||
await Modules.promises.hooks.fire('reactivateAddOn', userId, addOnCode)
|
||||
}
|
||||
|
||||
async function pauseSubscription(user, pauseCycles) {
|
||||
// only allow pausing on monthly plans not in a trial
|
||||
const { subscription } =
|
||||
@@ -419,6 +429,7 @@ module.exports = {
|
||||
previewAddonPurchase: callbackify(previewAddonPurchase),
|
||||
purchaseAddon: callbackify(purchaseAddon),
|
||||
removeAddon: callbackify(removeAddon),
|
||||
reactivateAddon: callbackify(reactivateAddon),
|
||||
pauseSubscription: callbackify(pauseSubscription),
|
||||
resumeSubscription: callbackify(resumeSubscription),
|
||||
revertPlanChange: callbackify(revertPlanChange),
|
||||
@@ -438,6 +449,7 @@ module.exports = {
|
||||
previewAddonPurchase,
|
||||
purchaseAddon,
|
||||
removeAddon,
|
||||
reactivateAddon,
|
||||
pauseSubscription,
|
||||
resumeSubscription,
|
||||
revertPlanChange,
|
||||
|
||||
@@ -185,6 +185,12 @@ export default {
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionController.removeAddon
|
||||
)
|
||||
webRouter.post(
|
||||
'/user/subscription/addon/:addOnCode/reactivate',
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionController.reactivateAddon
|
||||
)
|
||||
webRouter.post(
|
||||
'/user/subscription/cancel-pending',
|
||||
AuthenticationController.requireLogin(),
|
||||
|
||||
@@ -1365,7 +1365,10 @@
|
||||
"raw_logs_description": "",
|
||||
"react_history_tutorial_content": "",
|
||||
"react_history_tutorial_title": "",
|
||||
"reactivate": "",
|
||||
"reactivate_add_on_failed": "",
|
||||
"reactivate_subscription": "",
|
||||
"reactivating": "",
|
||||
"read_lines_from_path": "",
|
||||
"read_more": "",
|
||||
"read_more_about_compile_timeout_changes": "",
|
||||
|
||||
@@ -72,6 +72,7 @@ export function ActiveSubscription({
|
||||
}
|
||||
|
||||
const handlePlanChange = () => setModalIdShown('change-plan')
|
||||
|
||||
const handleCancelClick = (addOnCode: string) => {
|
||||
if (
|
||||
[AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE, AI_ADD_ON_CODE].includes(
|
||||
@@ -81,6 +82,7 @@ export function ActiveSubscription({
|
||||
setModalIdShown('cancel-ai-add-on')
|
||||
}
|
||||
}
|
||||
|
||||
const hasPendingPause = Boolean(
|
||||
subscription.payment.state === 'active' &&
|
||||
subscription.payment.remainingPauseCycles &&
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { Dropdown, DropdownMenu, DropdownToggle } from 'react-bootstrap'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import {
|
||||
ADD_ON_NAME,
|
||||
AI_ADD_ON_CODE,
|
||||
@@ -38,6 +43,8 @@ function resolveAddOnName(addOnCode: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type ReactivateState = 'ready' | 'reactivating' | 'error'
|
||||
|
||||
function AddOn({
|
||||
addOnCode,
|
||||
displayPrice,
|
||||
@@ -47,6 +54,22 @@ function AddOn({
|
||||
nextBillingDate,
|
||||
}: AddOnProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const [reactivateState, setReactivateState] =
|
||||
useState<ReactivateState>('ready')
|
||||
|
||||
const handleReactivateClick = (addOnCode: string) => {
|
||||
setReactivateState('reactivating')
|
||||
postJSON(`/user/subscription/addon/${addOnCode}/reactivate`)
|
||||
.then(() => {
|
||||
location.reload()
|
||||
})
|
||||
.catch(err => {
|
||||
debugConsole.error(err)
|
||||
setReactivateState('error')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="add-on-card">
|
||||
<div>
|
||||
@@ -60,17 +83,28 @@ function AddOn({
|
||||
<div className="add-on-card-content">
|
||||
<div className="heading">{resolveAddOnName(addOnCode)}</div>
|
||||
<div className="description small mt-1">
|
||||
{pendingCancellation
|
||||
? t(
|
||||
'your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on',
|
||||
{ nextBillingDate }
|
||||
)
|
||||
: isAnnual
|
||||
? t('x_price_per_year', { price: displayPrice })
|
||||
: t('x_price_per_month', { price: displayPrice })}
|
||||
{reactivateState === 'reactivating' ? (
|
||||
<>
|
||||
{t('reactivating')} <OLSpinner size="sm" />
|
||||
</>
|
||||
) : pendingCancellation ? (
|
||||
t(
|
||||
'your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on',
|
||||
{ nextBillingDate }
|
||||
)
|
||||
) : isAnnual ? (
|
||||
t('x_price_per_year', { price: displayPrice })
|
||||
) : (
|
||||
t('x_price_per_month', { price: displayPrice })
|
||||
)}
|
||||
</div>
|
||||
{reactivateState === 'error' && (
|
||||
<div className="small mt-1 text-danger">
|
||||
{t('reactivate_add_on_failed')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!pendingCancellation && (
|
||||
{reactivateState !== 'reactivating' && (
|
||||
<div className="ms-auto">
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
@@ -84,14 +118,24 @@ function AddOn({
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<OLDropdownMenuItem
|
||||
onClick={() => handleCancelClick(addOnCode)}
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
variant="danger"
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLDropdownMenuItem>
|
||||
{pendingCancellation ? (
|
||||
<OLDropdownMenuItem
|
||||
onClick={() => handleReactivateClick(addOnCode)}
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('reactivate')}
|
||||
</OLDropdownMenuItem>
|
||||
) : (
|
||||
<OLDropdownMenuItem
|
||||
onClick={() => handleCancelClick(addOnCode)}
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
variant="danger"
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLDropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -1779,7 +1779,10 @@
|
||||
"raw_logs_description": "Raw logs from the LaTeX compiler",
|
||||
"react_history_tutorial_content": "To compare a range of versions, use the <0></0> on the versions you want at the start and end of the range. To add a label or to download a version use the options in the three-dot menu. <1>Learn more about using Overleaf History.</1>",
|
||||
"react_history_tutorial_title": "History actions have a new home",
|
||||
"reactivate": "Reactivate",
|
||||
"reactivate_add_on_failed": "Something went wrong while reactivating your add-on. Please try again later.",
|
||||
"reactivate_subscription": "Reactivate your subscription",
|
||||
"reactivating": "Reactivating",
|
||||
"read_lines_from_path": "Read lines from __path__",
|
||||
"read_more": "Read more",
|
||||
"read_more_about_compile_timeout_changes": "Read more about changes to compile timeout limits",
|
||||
|
||||
@@ -403,6 +403,14 @@ describe('PaymentProviderEntities', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnReactivation()', function () {
|
||||
it('throws an AddOnNotPresentError', function () {
|
||||
expect(() =>
|
||||
this.subscription.getRequestForAddOnReactivation(this.addOn.code)
|
||||
).to.throw(Errors.AddOnNotPresentError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForGroupPlanUpgrade()', function () {
|
||||
it('returns a correct change request', function () {
|
||||
const changeRequest =
|
||||
@@ -454,61 +462,102 @@ describe('PaymentProviderEntities', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('without add-ons', function () {
|
||||
describe('with an add-on pending cancellation', function () {
|
||||
beforeEach(function () {
|
||||
const { PaymentProviderSubscription } = this.PaymentProviderEntities
|
||||
this.subscription = new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
planCode: 'regular-plan',
|
||||
planName: 'My Plan',
|
||||
planPrice: 10,
|
||||
subtotal: 10.99,
|
||||
taxRate: 0.2,
|
||||
taxAmount: 2.4,
|
||||
total: 14.4,
|
||||
currency: 'USD',
|
||||
})
|
||||
this.subscription.pendingChange =
|
||||
new PaymentProviderSubscriptionChange({
|
||||
subscription: this.subscription,
|
||||
nextPlanCode: this.subscription.planCode,
|
||||
nextPlanName: this.subscription.planName,
|
||||
nextPlanPrice: this.subscription.planPrice,
|
||||
nextAddOns: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAddOn()', function () {
|
||||
it('returns false for any add-on', function () {
|
||||
expect(this.subscription.hasAddOn('some-add-on')).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnPurchase()', function () {
|
||||
describe('getRequestForAddOnReactivation()', function () {
|
||||
it('returns a change request', function () {
|
||||
const {
|
||||
PaymentProviderSubscriptionChangeRequest,
|
||||
PaymentProviderSubscriptionAddOnUpdate,
|
||||
} = this.PaymentProviderEntities
|
||||
const changeRequest =
|
||||
this.subscription.getRequestForAddOnPurchase('some-add-on')
|
||||
this.subscription.getRequestForAddOnReactivation(this.addOn.code)
|
||||
expect(changeRequest).to.deep.equal(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: this.subscription,
|
||||
timeframe: 'now',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'some-add-on',
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
timeframe: 'term_end',
|
||||
addOnUpdates: [this.addOn.toAddOnUpdate()],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnRemoval()', function () {
|
||||
it('throws an AddOnNotPresentError', function () {
|
||||
it('throws an AddOnNotPresentError if given the wrong add-on', function () {
|
||||
expect(() =>
|
||||
this.subscription.getRequestForAddOnRemoval('some-add-on')
|
||||
this.subscription.getRequestForAddOnReactivation('some-add-on')
|
||||
).to.throw(Errors.AddOnNotPresentError)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('without add-ons', function () {
|
||||
beforeEach(function () {
|
||||
const { PaymentProviderSubscription } = this.PaymentProviderEntities
|
||||
this.subscription = new PaymentProviderSubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
planCode: 'regular-plan',
|
||||
planName: 'My Plan',
|
||||
planPrice: 10,
|
||||
subtotal: 10.99,
|
||||
taxRate: 0.2,
|
||||
taxAmount: 2.4,
|
||||
total: 14.4,
|
||||
currency: 'USD',
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAddOn()', function () {
|
||||
it('returns false for any add-on', function () {
|
||||
expect(this.subscription.hasAddOn('some-add-on')).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnPurchase()', function () {
|
||||
it('returns a change request', function () {
|
||||
const {
|
||||
PaymentProviderSubscriptionChangeRequest,
|
||||
PaymentProviderSubscriptionAddOnUpdate,
|
||||
} = this.PaymentProviderEntities
|
||||
const changeRequest =
|
||||
this.subscription.getRequestForAddOnPurchase('some-add-on')
|
||||
expect(changeRequest).to.deep.equal(
|
||||
new PaymentProviderSubscriptionChangeRequest({
|
||||
subscription: this.subscription,
|
||||
timeframe: 'now',
|
||||
addOnUpdates: [
|
||||
new PaymentProviderSubscriptionAddOnUpdate({
|
||||
code: 'some-add-on',
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnRemoval()', function () {
|
||||
it('throws an AddOnNotPresentError', function () {
|
||||
expect(() =>
|
||||
this.subscription.getRequestForAddOnRemoval('some-add-on')
|
||||
).to.throw(Errors.AddOnNotPresentError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnReactivation()', function () {
|
||||
it('throws an AddOnNotPresentError', function () {
|
||||
expect(() =>
|
||||
this.subscription.getRequestForAddOnReactivation('some-add-on')
|
||||
).to.throw(Errors.AddOnNotPresentError)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PaymentProviderSubscriptionChange', function () {
|
||||
|
||||
Reference in New Issue
Block a user