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:
Eric Mc Sween
2024-11-18 10:24:11 -05:00
committed by Copybot
parent d84d4dd093
commit 087b612e16
4 changed files with 164 additions and 37 deletions

View File

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

View File

@@ -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
}
/**

View File

@@ -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,

View File

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