mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #21869 from overleaf/em-repurchase-add-on
Repurchase the AI add-on when changing plans GitOrigin-RevId: 1035e57af4c254fc73464f14010e4ba7e18cfe80
This commit is contained in:
@@ -207,41 +207,53 @@ function subscriptionIsCanceledOrExpired(subscription) {
|
||||
/**
|
||||
* Build a RecurlySubscription from Recurly API data
|
||||
*
|
||||
* @param {recurly.Subscription} subscription
|
||||
* @param {recurly.Subscription} apiSubscription
|
||||
* @return {RecurlySubscription}
|
||||
*/
|
||||
function subscriptionFromApi(subscription) {
|
||||
function subscriptionFromApi(apiSubscription) {
|
||||
if (
|
||||
subscription.uuid == null ||
|
||||
subscription.plan == null ||
|
||||
subscription.plan.code == null ||
|
||||
subscription.plan.name == null ||
|
||||
subscription.account == null ||
|
||||
subscription.account.code == null ||
|
||||
subscription.unitAmount == null ||
|
||||
subscription.subtotal == null ||
|
||||
subscription.total == null ||
|
||||
subscription.currency == null ||
|
||||
subscription.currentPeriodStartedAt == null ||
|
||||
subscription.currentPeriodEndsAt == null
|
||||
apiSubscription.uuid == null ||
|
||||
apiSubscription.plan == null ||
|
||||
apiSubscription.plan.code == null ||
|
||||
apiSubscription.plan.name == null ||
|
||||
apiSubscription.account == null ||
|
||||
apiSubscription.account.code == null ||
|
||||
apiSubscription.unitAmount == null ||
|
||||
apiSubscription.subtotal == null ||
|
||||
apiSubscription.total == null ||
|
||||
apiSubscription.currency == null ||
|
||||
apiSubscription.currentPeriodStartedAt == null ||
|
||||
apiSubscription.currentPeriodEndsAt == null
|
||||
) {
|
||||
throw new OError('Invalid Recurly subscription', { subscription })
|
||||
throw new OError('Invalid Recurly subscription', {
|
||||
subscription: apiSubscription,
|
||||
})
|
||||
}
|
||||
return new RecurlySubscription({
|
||||
id: subscription.uuid,
|
||||
userId: subscription.account.code,
|
||||
planCode: subscription.plan.code,
|
||||
planName: subscription.plan.name,
|
||||
planPrice: subscription.unitAmount,
|
||||
addOns: (subscription.addOns ?? []).map(subscriptionAddOnFromApi),
|
||||
subtotal: subscription.subtotal,
|
||||
taxRate: subscription.taxInfo?.rate ?? 0,
|
||||
taxAmount: subscription.tax ?? 0,
|
||||
total: subscription.total,
|
||||
currency: subscription.currency,
|
||||
periodStart: subscription.currentPeriodStartedAt,
|
||||
periodEnd: subscription.currentPeriodEndsAt,
|
||||
|
||||
const subscription = new RecurlySubscription({
|
||||
id: apiSubscription.uuid,
|
||||
userId: apiSubscription.account.code,
|
||||
planCode: apiSubscription.plan.code,
|
||||
planName: apiSubscription.plan.name,
|
||||
planPrice: apiSubscription.unitAmount,
|
||||
addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
|
||||
subtotal: apiSubscription.subtotal,
|
||||
taxRate: apiSubscription.taxInfo?.rate ?? 0,
|
||||
taxAmount: apiSubscription.tax ?? 0,
|
||||
total: apiSubscription.total,
|
||||
currency: apiSubscription.currency,
|
||||
periodStart: apiSubscription.currentPeriodStartedAt,
|
||||
periodEnd: apiSubscription.currentPeriodEndsAt,
|
||||
})
|
||||
|
||||
if (apiSubscription.pendingChange != null) {
|
||||
subscription.pendingChange = subscriptionChangeFromApi(
|
||||
subscription,
|
||||
apiSubscription.pendingChange
|
||||
)
|
||||
}
|
||||
|
||||
return subscription
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,14 +301,22 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) {
|
||||
const nextAddOns = (subscriptionChange.addOns ?? []).map(
|
||||
subscriptionAddOnFromApi
|
||||
)
|
||||
|
||||
let immediateCharge =
|
||||
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
|
||||
for (const creditInvoice of subscriptionChange.invoiceCollection
|
||||
?.creditInvoices ?? []) {
|
||||
// The credit invoice totals are already negative
|
||||
immediateCharge += creditInvoice.total ?? 0
|
||||
}
|
||||
|
||||
return new RecurlySubscriptionChange({
|
||||
subscription,
|
||||
nextPlanCode: subscriptionChange.plan.code,
|
||||
nextPlanName: subscriptionChange.plan.name,
|
||||
nextPlanPrice: subscriptionChange.unitAmount,
|
||||
nextAddOns,
|
||||
immediateCharge:
|
||||
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0,
|
||||
immediateCharge,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class RecurlySubscription {
|
||||
* @param {number} props.total
|
||||
* @param {Date} props.periodStart
|
||||
* @param {Date} props.periodEnd
|
||||
* @param {RecurlySubscriptionChange} [props.pendingChange]
|
||||
*/
|
||||
constructor(props) {
|
||||
this.id = props.id
|
||||
@@ -39,8 +40,15 @@ class RecurlySubscription {
|
||||
this.total = props.total
|
||||
this.periodStart = props.periodStart
|
||||
this.periodEnd = props.periodEnd
|
||||
this.pendingChange = props.pendingChange ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this subscription currently has the given add-on
|
||||
*
|
||||
* @param {string} code
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAddOn(code) {
|
||||
return this.addOns.some(addOn => addOn.code === code)
|
||||
}
|
||||
@@ -54,6 +62,25 @@ class RecurlySubscription {
|
||||
return isStandaloneAiAddOnPlanCode(this.planCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this subcription will have the given add-on next billing
|
||||
* period.
|
||||
*
|
||||
* There are two cases: either the subscription already has the add-on and
|
||||
* won't change next period, or the subscription will change next period and
|
||||
* the change includes the add-on.
|
||||
*
|
||||
* @param {string} code
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAddOnNextPeriod(code) {
|
||||
if (this.pendingChange != null) {
|
||||
return this.pendingChange.nextAddOns.some(addOn => addOn.code === code)
|
||||
} else {
|
||||
return this.hasAddOn(code)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change this subscription's plan
|
||||
*
|
||||
@@ -70,16 +97,31 @@ class RecurlySubscription {
|
||||
if (newPlan == null) {
|
||||
throw new OError('Unable to find plan in settings', { planCode })
|
||||
}
|
||||
const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
|
||||
const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
|
||||
currentPlan,
|
||||
newPlan
|
||||
)
|
||||
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
|
||||
return new RecurlySubscriptionChangeRequest({
|
||||
|
||||
const changeRequest = new RecurlySubscriptionChangeRequest({
|
||||
subscription: this,
|
||||
timeframe,
|
||||
timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
|
||||
planCode,
|
||||
})
|
||||
|
||||
// Carry the AI add-on to the new plan if applicable
|
||||
if (
|
||||
this.isStandaloneAiAddOn() ||
|
||||
(!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
|
||||
(shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
|
||||
) {
|
||||
const addOnUpdate = new RecurlySubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
})
|
||||
changeRequest.addOnUpdates = [addOnUpdate]
|
||||
}
|
||||
|
||||
return changeRequest
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ const { expect } = require('chai')
|
||||
const recurly = require('recurly')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const {
|
||||
RecurlySubscription,
|
||||
RecurlySubscriptionChangeRequest,
|
||||
RecurlySubscriptionAddOnUpdate,
|
||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||
@@ -35,7 +36,7 @@ describe('RecurlyClient', function () {
|
||||
preTaxTotal: 2,
|
||||
}
|
||||
|
||||
this.subscription = {
|
||||
this.subscription = new RecurlySubscription({
|
||||
id: 'subscription-id',
|
||||
userId: 'user-id',
|
||||
currency: 'EUR',
|
||||
@@ -49,7 +50,7 @@ describe('RecurlyClient', function () {
|
||||
total: 16.5,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
}
|
||||
})
|
||||
|
||||
this.recurlySubscription = {
|
||||
uuid: this.subscription.id,
|
||||
|
||||
@@ -4,9 +4,11 @@ const SandboxedModule = require('sandboxed-module')
|
||||
const { expect } = require('chai')
|
||||
const Errors = require('../../../../app/src/Features/Subscription/Errors')
|
||||
const {
|
||||
AI_ADD_ON_CODE,
|
||||
RecurlySubscriptionChangeRequest,
|
||||
RecurlySubscriptionChange,
|
||||
RecurlySubscription,
|
||||
RecurlySubscriptionAddOnUpdate,
|
||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||
|
||||
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
|
||||
@@ -16,6 +18,7 @@ describe('RecurlyEntities', function () {
|
||||
beforeEach(function () {
|
||||
this.Settings = {
|
||||
plans: [
|
||||
{ planCode: 'assistant-annual', price_in_cents: 5900 },
|
||||
{ planCode: 'cheap-plan', price_in_cents: 500 },
|
||||
{ planCode: 'regular-plan', price_in_cents: 1000 },
|
||||
{ planCode: 'premium-plan', price_in_cents: 2000 },
|
||||
@@ -92,6 +95,67 @@ describe('RecurlyEntities', function () {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the AI add-on on upgrades', function () {
|
||||
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
|
||||
this.addOn.code = AI_ADD_ON_CODE
|
||||
const changeRequest =
|
||||
this.subscription.getRequestForPlanChange('premium-plan')
|
||||
expect(changeRequest).to.deep.equal(
|
||||
new RecurlySubscriptionChangeRequest({
|
||||
subscription: this.subscription,
|
||||
timeframe: 'now',
|
||||
planCode: 'premium-plan',
|
||||
addOnUpdates: [
|
||||
new RecurlySubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the AI add-on on downgrades', function () {
|
||||
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
|
||||
this.addOn.code = AI_ADD_ON_CODE
|
||||
const changeRequest =
|
||||
this.subscription.getRequestForPlanChange('cheap-plan')
|
||||
expect(changeRequest).to.deep.equal(
|
||||
new RecurlySubscriptionChangeRequest({
|
||||
subscription: this.subscription,
|
||||
timeframe: 'term_end',
|
||||
planCode: 'cheap-plan',
|
||||
addOnUpdates: [
|
||||
new RecurlySubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
|
||||
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
|
||||
this.subscription.planCode = 'assistant-annual'
|
||||
this.subscription.addOns = []
|
||||
const changeRequest =
|
||||
this.subscription.getRequestForPlanChange('cheap-plan')
|
||||
expect(changeRequest).to.deep.equal(
|
||||
new RecurlySubscriptionChangeRequest({
|
||||
subscription: this.subscription,
|
||||
timeframe: 'term_end',
|
||||
planCode: 'cheap-plan',
|
||||
addOnUpdates: [
|
||||
new RecurlySubscriptionAddOnUpdate({
|
||||
code: AI_ADD_ON_CODE,
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnPurchase()', function () {
|
||||
|
||||
Reference in New Issue
Block a user