diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 5c3f464f68..d442ad45be 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -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, }) } diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index 263442c510..a80db92321 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -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 } /** diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 9e861fead3..2267bf1c19 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -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, diff --git a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js index aed3951ed3..7331118739 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js @@ -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 () {