PaymentProviderEntities.test.mjs 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962
  1. import { vi, expect } from 'vitest'
  2. import Errors from '../../../../app/src/Features/Subscription/Errors.mjs'
  3. import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs'
  4. import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'
  5. import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs'
  6. const {
  7. PaymentProviderSubscriptionChangeRequest,
  8. PaymentProviderSubscriptionUpdateRequest,
  9. PaymentProviderSubscriptionChange,
  10. PaymentProviderSubscription,
  11. PaymentProviderSubscriptionAddOnUpdate,
  12. } = PaymentProviderEntities
  13. const MODULE_PATH =
  14. '../../../../app/src/Features/Subscription/PaymentProviderEntities'
  15. describe('PaymentProviderEntities', function () {
  16. describe('PaymentProviderSubscription', function () {
  17. beforeEach(async function (ctx) {
  18. ctx.Settings = {
  19. plans: [
  20. { planCode: 'assistant-annual', price_in_cents: 5900 },
  21. { planCode: 'cheap-plan', price_in_cents: 500 },
  22. { planCode: 'regular-plan', price_in_cents: 1000 },
  23. { planCode: 'premium-plan', price_in_cents: 2000 },
  24. {
  25. planCode: 'group_collaborator_10_enterprise',
  26. price_in_cents: 10000,
  27. },
  28. ],
  29. features: [],
  30. }
  31. vi.doMock('@overleaf/settings', () => ({
  32. default: ctx.Settings,
  33. }))
  34. vi.doMock(
  35. '../../../../app/src/Features/Subscription/Errors',
  36. () => Errors
  37. )
  38. vi.doMock(
  39. '../../../../app/src/Features/Subscription/SubscriptionHelper',
  40. () => ({
  41. default: SubscriptionHelper,
  42. })
  43. )
  44. ctx.PaymentProviderEntities = (await import(MODULE_PATH)).default
  45. })
  46. describe('with add-ons', function () {
  47. beforeEach(function (ctx) {
  48. const {
  49. PaymentProviderSubscription,
  50. PaymentProviderSubscriptionAddOn,
  51. } = ctx.PaymentProviderEntities
  52. ctx.addOn = new PaymentProviderSubscriptionAddOn({
  53. code: 'add-on-code',
  54. name: 'My Add-On',
  55. quantity: 1,
  56. unitPrice: 2,
  57. })
  58. ctx.subscription = new PaymentProviderSubscription({
  59. id: 'subscription-id',
  60. userId: 'user-id',
  61. planCode: 'regular-plan',
  62. planName: 'My Plan',
  63. planPrice: 10,
  64. addOns: [ctx.addOn],
  65. subtotal: 10.99,
  66. taxRate: 0.2,
  67. taxAmount: 2.4,
  68. total: 14.4,
  69. currency: 'USD',
  70. })
  71. })
  72. describe('hasAddOn()', function () {
  73. it('returns true if the subscription has the given add-on', function (ctx) {
  74. expect(ctx.subscription.hasAddOn(ctx.addOn.code)).to.be.true
  75. })
  76. it("returns false if the subscription doesn't have the given add-on", function (ctx) {
  77. expect(ctx.subscription.hasAddOn('another-add-on')).to.be.false
  78. })
  79. })
  80. describe('getRequestForPlanChange()', function () {
  81. it('returns a change request for upgrades', function (ctx) {
  82. const { PaymentProviderSubscriptionChangeRequest } =
  83. ctx.PaymentProviderEntities
  84. const changeRequest = ctx.subscription.getRequestForPlanChange(
  85. 'premium-plan',
  86. 1,
  87. ctx.subscription.shouldPlanChangeAtTermEnd('premium-plan')
  88. )
  89. expect(changeRequest).to.deep.equal(
  90. new PaymentProviderSubscriptionChangeRequest({
  91. subscription: ctx.subscription,
  92. timeframe: 'now',
  93. planCode: 'premium-plan',
  94. addOnUpdates: [
  95. new PaymentProviderSubscriptionAddOnUpdate({
  96. code: ctx.addOn.code,
  97. quantity: ctx.addOn.quantity,
  98. unitPrice: ctx.addOn.unitPrice,
  99. }),
  100. ],
  101. })
  102. )
  103. })
  104. it('returns a change request for downgrades', function (ctx) {
  105. const { PaymentProviderSubscriptionChangeRequest } =
  106. ctx.PaymentProviderEntities
  107. const changeRequest = ctx.subscription.getRequestForPlanChange(
  108. 'cheap-plan',
  109. 1,
  110. ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
  111. )
  112. expect(changeRequest).to.deep.equal(
  113. new PaymentProviderSubscriptionChangeRequest({
  114. subscription: ctx.subscription,
  115. timeframe: 'term_end',
  116. planCode: 'cheap-plan',
  117. addOnUpdates: [
  118. new PaymentProviderSubscriptionAddOnUpdate({
  119. code: ctx.addOn.code,
  120. quantity: ctx.addOn.quantity,
  121. unitPrice: ctx.addOn.unitPrice,
  122. }),
  123. ],
  124. })
  125. )
  126. })
  127. it('returns a change request for downgrades while on trial', function (ctx) {
  128. const fiveDaysFromNow = new Date()
  129. fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
  130. ctx.subscription.trialPeriodEnd = fiveDaysFromNow
  131. const { PaymentProviderSubscriptionChangeRequest } =
  132. ctx.PaymentProviderEntities
  133. const changeRequest = ctx.subscription.getRequestForPlanChange(
  134. 'cheap-plan',
  135. 1,
  136. ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
  137. )
  138. expect(changeRequest).to.deep.equal(
  139. new PaymentProviderSubscriptionChangeRequest({
  140. subscription: ctx.subscription,
  141. timeframe: 'now',
  142. planCode: 'cheap-plan',
  143. addOnUpdates: [
  144. new PaymentProviderSubscriptionAddOnUpdate({
  145. code: ctx.addOn.code,
  146. quantity: ctx.addOn.quantity,
  147. unitPrice: ctx.addOn.unitPrice,
  148. }),
  149. ],
  150. })
  151. )
  152. })
  153. it('preserves the AI add-on on upgrades', function (ctx) {
  154. const { PaymentProviderSubscriptionChangeRequest } =
  155. ctx.PaymentProviderEntities
  156. ctx.addOn.code = AI_ADD_ON_CODE
  157. const changeRequest = ctx.subscription.getRequestForPlanChange(
  158. 'premium-plan',
  159. 1,
  160. ctx.subscription.shouldPlanChangeAtTermEnd('premium-plan')
  161. )
  162. expect(changeRequest).to.deep.equal(
  163. new PaymentProviderSubscriptionChangeRequest({
  164. subscription: ctx.subscription,
  165. timeframe: 'now',
  166. planCode: 'premium-plan',
  167. addOnUpdates: [
  168. new PaymentProviderSubscriptionAddOnUpdate({
  169. code: AI_ADD_ON_CODE,
  170. quantity: 1,
  171. unitPrice: ctx.addOn.unitPrice,
  172. }),
  173. ],
  174. })
  175. )
  176. })
  177. it('preserves the AI add-on on downgrades', function (ctx) {
  178. const { PaymentProviderSubscriptionChangeRequest } =
  179. ctx.PaymentProviderEntities
  180. ctx.addOn.code = AI_ADD_ON_CODE
  181. const changeRequest = ctx.subscription.getRequestForPlanChange(
  182. 'cheap-plan',
  183. 1,
  184. ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
  185. )
  186. expect(changeRequest).to.deep.equal(
  187. new PaymentProviderSubscriptionChangeRequest({
  188. subscription: ctx.subscription,
  189. timeframe: 'term_end',
  190. planCode: 'cheap-plan',
  191. addOnUpdates: [
  192. new PaymentProviderSubscriptionAddOnUpdate({
  193. code: AI_ADD_ON_CODE,
  194. quantity: 1,
  195. unitPrice: ctx.addOn.unitPrice,
  196. }),
  197. ],
  198. })
  199. )
  200. })
  201. it('preserves the AI add-on on upgrades from the standalone AI plan', function (ctx) {
  202. const { PaymentProviderSubscriptionChangeRequest } =
  203. ctx.PaymentProviderEntities
  204. ctx.subscription.planCode = 'assistant-annual'
  205. ctx.subscription.addOns = []
  206. const changeRequest = ctx.subscription.getRequestForPlanChange(
  207. 'cheap-plan',
  208. 1,
  209. ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
  210. )
  211. expect(changeRequest).to.deep.equal(
  212. new PaymentProviderSubscriptionChangeRequest({
  213. subscription: ctx.subscription,
  214. timeframe: 'now',
  215. planCode: 'cheap-plan',
  216. addOnUpdates: [
  217. new PaymentProviderSubscriptionAddOnUpdate({
  218. code: AI_ADD_ON_CODE,
  219. quantity: 1,
  220. }),
  221. ],
  222. })
  223. )
  224. })
  225. it('upgrade from individual to group plan for Stripe subscription', function (ctx) {
  226. ctx.subscription.service = 'stripe-uk'
  227. const { PaymentProviderSubscriptionChangeRequest } =
  228. ctx.PaymentProviderEntities
  229. const changeRequest = ctx.subscription.getRequestForPlanChange(
  230. 'group_collaborator',
  231. 10,
  232. ctx.subscription.shouldPlanChangeAtTermEnd(
  233. 'group_collaborator_10_enterprise'
  234. )
  235. )
  236. expect(changeRequest).to.deep.equal(
  237. new PaymentProviderSubscriptionChangeRequest({
  238. subscription: ctx.subscription,
  239. timeframe: 'now',
  240. planCode: 'group_collaborator',
  241. addOnUpdates: [
  242. new PaymentProviderSubscriptionAddOnUpdate({
  243. code: ctx.addOn.code,
  244. quantity: ctx.addOn.quantity,
  245. unitPrice: ctx.addOn.unitPrice,
  246. }),
  247. new PaymentProviderSubscriptionAddOnUpdate({
  248. code: 'additional-license',
  249. quantity: 10,
  250. }),
  251. ],
  252. })
  253. )
  254. })
  255. it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function (ctx) {
  256. ctx.subscription.service = 'stripe-uk'
  257. const { PaymentProviderSubscriptionChangeRequest } =
  258. ctx.PaymentProviderEntities
  259. ctx.addOn.code = AI_ADD_ON_CODE
  260. const changeRequest = ctx.subscription.getRequestForPlanChange(
  261. 'group_collaborator',
  262. 10,
  263. ctx.subscription.shouldPlanChangeAtTermEnd(
  264. 'group_collaborator_10_enterprise'
  265. )
  266. )
  267. expect(changeRequest).to.deep.equal(
  268. new PaymentProviderSubscriptionChangeRequest({
  269. subscription: ctx.subscription,
  270. timeframe: 'now',
  271. planCode: 'group_collaborator',
  272. addOnUpdates: [
  273. new PaymentProviderSubscriptionAddOnUpdate({
  274. code: AI_ADD_ON_CODE,
  275. quantity: 1,
  276. unitPrice: ctx.addOn.unitPrice,
  277. }),
  278. new PaymentProviderSubscriptionAddOnUpdate({
  279. code: 'additional-license',
  280. quantity: 10,
  281. }),
  282. ],
  283. })
  284. )
  285. })
  286. describe('with pending add-on removal', function () {
  287. beforeEach(function (ctx) {
  288. // Reset to a subscription with an add-on and pending removal
  289. const {
  290. PaymentProviderSubscription,
  291. PaymentProviderSubscriptionAddOn,
  292. } = ctx.PaymentProviderEntities
  293. ctx.addOn = new PaymentProviderSubscriptionAddOn({
  294. code: 'add-on-code',
  295. name: 'My Add-On',
  296. quantity: 1,
  297. unitPrice: 2,
  298. })
  299. ctx.subscription = new PaymentProviderSubscription({
  300. id: 'subscription-id',
  301. userId: 'user-id',
  302. planCode: 'regular-plan',
  303. planName: 'My Plan',
  304. planPrice: 10,
  305. addOns: [ctx.addOn],
  306. subtotal: 10.99,
  307. taxRate: 0.2,
  308. taxAmount: 2.4,
  309. total: 14.4,
  310. currency: 'USD',
  311. })
  312. // Set up a pending change that removes the add-on at term_end
  313. ctx.subscription.pendingChange =
  314. new PaymentProviderSubscriptionChange({
  315. subscription: ctx.subscription,
  316. nextPlanCode: ctx.subscription.planCode,
  317. nextPlanName: ctx.subscription.planName,
  318. nextPlanPrice: ctx.subscription.planPrice,
  319. nextAddOns: [], // Add-on is scheduled to be removed
  320. })
  321. })
  322. it('preserves current add-ons for immediate upgrade', function (ctx) {
  323. const changeRequest = ctx.subscription.getRequestForPlanChange(
  324. 'premium-plan',
  325. 1,
  326. false // immediate change
  327. )
  328. expect(changeRequest.timeframe).to.equal('now')
  329. expect(changeRequest.addOnUpdates).to.deep.equal([
  330. new PaymentProviderSubscriptionAddOnUpdate({
  331. code: ctx.addOn.code,
  332. quantity: ctx.addOn.quantity,
  333. unitPrice: ctx.addOn.unitPrice,
  334. }),
  335. ])
  336. })
  337. it('stacks with pending add-on removal for term_end downgrade', function (ctx) {
  338. const changeRequest = ctx.subscription.getRequestForPlanChange(
  339. 'cheap-plan',
  340. 1,
  341. true // term_end change
  342. )
  343. expect(changeRequest.timeframe).to.equal('term_end')
  344. // Should have NO add-ons because the pending change already removed them
  345. expect(changeRequest.addOnUpdates).to.deep.equal([])
  346. })
  347. })
  348. })
  349. describe('getRequestForAddOnPurchase()', function () {
  350. it('returns a change request', function (ctx) {
  351. const {
  352. PaymentProviderSubscriptionChangeRequest,
  353. PaymentProviderSubscriptionAddOnUpdate,
  354. } = ctx.PaymentProviderEntities
  355. const changeRequest =
  356. ctx.subscription.getRequestForAddOnPurchase('another-add-on')
  357. expect(changeRequest).to.deep.equal(
  358. new PaymentProviderSubscriptionChangeRequest({
  359. subscription: ctx.subscription,
  360. timeframe: 'now',
  361. addOnUpdates: [
  362. new PaymentProviderSubscriptionAddOnUpdate({
  363. code: ctx.addOn.code,
  364. quantity: ctx.addOn.quantity,
  365. unitPrice: ctx.addOn.unitPrice,
  366. }),
  367. new PaymentProviderSubscriptionAddOnUpdate({
  368. code: 'another-add-on',
  369. quantity: 1,
  370. }),
  371. ],
  372. })
  373. )
  374. })
  375. it('returns a change request with quantity and unit price specified', function (ctx) {
  376. const {
  377. PaymentProviderSubscriptionChangeRequest,
  378. PaymentProviderSubscriptionAddOnUpdate,
  379. } = ctx.PaymentProviderEntities
  380. const quantity = 5
  381. const unitPrice = 10
  382. const changeRequest = ctx.subscription.getRequestForAddOnPurchase(
  383. 'another-add-on',
  384. quantity,
  385. unitPrice
  386. )
  387. expect(changeRequest).to.deep.equal(
  388. new PaymentProviderSubscriptionChangeRequest({
  389. subscription: ctx.subscription,
  390. timeframe: 'now',
  391. addOnUpdates: [
  392. new PaymentProviderSubscriptionAddOnUpdate({
  393. code: ctx.addOn.code,
  394. quantity: ctx.addOn.quantity,
  395. unitPrice: ctx.addOn.unitPrice,
  396. }),
  397. new PaymentProviderSubscriptionAddOnUpdate({
  398. code: 'another-add-on',
  399. quantity,
  400. unitPrice,
  401. }),
  402. ],
  403. })
  404. )
  405. })
  406. it('throws a DuplicateAddOnError if the subscription already has the add-on', function (ctx) {
  407. expect(() =>
  408. ctx.subscription.getRequestForAddOnPurchase(ctx.addOn.code)
  409. ).to.throw(Errors.DuplicateAddOnError)
  410. })
  411. describe('with pending plan downgrade', function () {
  412. beforeEach(function (ctx) {
  413. // Scenario: downgrade is scheduled, and then they want to buy an add-on immediately
  414. const {
  415. PaymentProviderSubscription,
  416. PaymentProviderSubscriptionChange,
  417. } = ctx.PaymentProviderEntities
  418. ctx.subscription = new PaymentProviderSubscription({
  419. id: 'subscription-id',
  420. userId: 'user-id',
  421. planCode: 'premium-plan',
  422. planName: 'Premium Plan',
  423. planPrice: 20,
  424. addOns: [],
  425. subtotal: 20,
  426. taxRate: 0.2,
  427. taxAmount: 4,
  428. total: 24,
  429. currency: 'USD',
  430. })
  431. // Pending downgrade to cheaper plan at term_end
  432. ctx.subscription.pendingChange =
  433. new PaymentProviderSubscriptionChange({
  434. subscription: ctx.subscription,
  435. nextPlanCode: 'cheap-plan',
  436. nextPlanName: 'Cheap Plan',
  437. nextPlanPrice: 5,
  438. nextAddOns: [],
  439. })
  440. })
  441. it('uses current add-ons for immediate add-on purchase', function (ctx) {
  442. const changeRequest =
  443. ctx.subscription.getRequestForAddOnPurchase('assistant')
  444. expect(changeRequest.timeframe).to.equal('now')
  445. // Should only have the new add-on, based on current state, client will reapply the pending change
  446. expect(changeRequest.addOnUpdates).to.deep.equal([
  447. new PaymentProviderSubscriptionAddOnUpdate({
  448. code: 'assistant',
  449. quantity: 1,
  450. }),
  451. ])
  452. })
  453. })
  454. })
  455. describe('getRequestForAddOnUpdate()', function () {
  456. it('returns a change request', function (ctx) {
  457. const {
  458. PaymentProviderSubscriptionChangeRequest,
  459. PaymentProviderSubscriptionAddOnUpdate,
  460. } = ctx.PaymentProviderEntities
  461. const newQuantity = 2
  462. const changeRequest = ctx.subscription.getRequestForAddOnUpdate(
  463. 'add-on-code',
  464. newQuantity
  465. )
  466. expect(changeRequest).to.deep.equal(
  467. new PaymentProviderSubscriptionChangeRequest({
  468. subscription: ctx.subscription,
  469. timeframe: 'now',
  470. addOnUpdates: [
  471. new PaymentProviderSubscriptionAddOnUpdate({
  472. code: ctx.addOn.code,
  473. quantity: newQuantity,
  474. unitPrice: ctx.addOn.unitPrice,
  475. }),
  476. ],
  477. })
  478. )
  479. })
  480. it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function (ctx) {
  481. expect(() =>
  482. ctx.subscription.getRequestForAddOnUpdate('another-add-on', 2)
  483. ).to.throw(Errors.AddOnNotPresentError)
  484. })
  485. })
  486. describe('getRequestForAddOnRemoval()', function () {
  487. it('returns a change request', function (ctx) {
  488. const changeRequest = ctx.subscription.getRequestForAddOnRemoval(
  489. ctx.addOn.code
  490. )
  491. expect(changeRequest).to.deep.equal(
  492. new PaymentProviderSubscriptionChangeRequest({
  493. subscription: ctx.subscription,
  494. timeframe: 'term_end',
  495. addOnUpdates: [],
  496. })
  497. )
  498. })
  499. it('returns a change request when in trial', function (ctx) {
  500. const fiveDaysFromNow = new Date()
  501. fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
  502. ctx.subscription.trialPeriodEnd = fiveDaysFromNow
  503. const changeRequest = ctx.subscription.getRequestForAddOnRemoval(
  504. ctx.addOn.code
  505. )
  506. expect(changeRequest).to.deep.equal(
  507. new PaymentProviderSubscriptionChangeRequest({
  508. subscription: ctx.subscription,
  509. timeframe: 'now',
  510. addOnUpdates: [],
  511. })
  512. )
  513. })
  514. it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function (ctx) {
  515. expect(() =>
  516. ctx.subscription.getRequestForAddOnRemoval('another-add-on')
  517. ).to.throw(Errors.AddOnNotPresentError)
  518. })
  519. describe('with pending changes', function () {
  520. beforeEach(function (ctx) {
  521. // Scenario: a subscription has two add-ons, one already scheduled for removal
  522. const {
  523. PaymentProviderSubscription,
  524. PaymentProviderSubscriptionAddOn,
  525. } = ctx.PaymentProviderEntities
  526. ctx.addOn1 = new PaymentProviderSubscriptionAddOn({
  527. code: 'addon-1',
  528. name: 'Add-On 1',
  529. quantity: 1,
  530. unitPrice: 2,
  531. })
  532. ctx.addOn2 = new PaymentProviderSubscriptionAddOn({
  533. code: 'addon-2',
  534. name: 'Add-On 2',
  535. quantity: 1,
  536. unitPrice: 3,
  537. })
  538. ctx.subscription = new PaymentProviderSubscription({
  539. id: 'subscription-id',
  540. userId: 'user-id',
  541. planCode: 'regular-plan',
  542. planName: 'My Plan',
  543. planPrice: 10,
  544. addOns: [ctx.addOn1, ctx.addOn2],
  545. subtotal: 10.99,
  546. taxRate: 0.2,
  547. taxAmount: 2.4,
  548. total: 14.4,
  549. currency: 'USD',
  550. })
  551. // Set up a pending change that removes addon-1 at term_end
  552. ctx.subscription.pendingChange =
  553. new PaymentProviderSubscriptionChange({
  554. subscription: ctx.subscription,
  555. nextPlanCode: ctx.subscription.planCode,
  556. nextPlanName: ctx.subscription.planName,
  557. nextPlanPrice: ctx.subscription.planPrice,
  558. nextAddOns: [ctx.addOn2], // Only addon-2 remains
  559. })
  560. })
  561. it('stacks multiple add-on removals at term_end', function (ctx) {
  562. const changeRequest =
  563. ctx.subscription.getRequestForAddOnRemoval('addon-2')
  564. expect(changeRequest.timeframe).to.equal('term_end')
  565. expect(changeRequest.addOnUpdates).to.deep.equal([]) // empty because both are scheduled for removal
  566. })
  567. })
  568. })
  569. describe('getRequestForAddOnReactivation()', function () {
  570. it('throws an AddOnNotPresentError', function (ctx) {
  571. expect(() =>
  572. ctx.subscription.getRequestForAddOnReactivation(ctx.addOn.code)
  573. ).to.throw(Errors.AddOnNotPresentError)
  574. })
  575. })
  576. describe('getRequestForGroupPlanUpgrade()', function () {
  577. it('returns a correct change request', function (ctx) {
  578. const changeRequest =
  579. ctx.subscription.getRequestForGroupPlanUpgrade('test_plan_code')
  580. const addOns = [
  581. new PaymentProviderSubscriptionAddOnUpdate({
  582. code: 'add-on-code',
  583. quantity: 1,
  584. }),
  585. ]
  586. expect(changeRequest).to.deep.equal(
  587. new PaymentProviderSubscriptionChangeRequest({
  588. subscription: ctx.subscription,
  589. timeframe: 'now',
  590. addOnUpdates: addOns,
  591. planCode: 'test_plan_code',
  592. })
  593. )
  594. })
  595. })
  596. describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () {
  597. it('returns a correct update request', function (ctx) {
  598. const updateRequest =
  599. ctx.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
  600. 'O12345',
  601. 'T&C copy'
  602. )
  603. expect(updateRequest).to.deep.equal(
  604. new PaymentProviderSubscriptionUpdateRequest({
  605. subscription: ctx.subscription,
  606. poNumber: 'O12345',
  607. termsAndConditions: 'T&C copy',
  608. })
  609. )
  610. })
  611. })
  612. describe('getRequestForTermsAndConditionsUpdate()', function () {
  613. it('returns a correct update request', function (ctx) {
  614. const updateRequest =
  615. ctx.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
  616. expect(updateRequest).to.deep.equal(
  617. new PaymentProviderSubscriptionUpdateRequest({
  618. subscription: ctx.subscription,
  619. termsAndConditions: 'T&C copy',
  620. })
  621. )
  622. })
  623. })
  624. describe('getRequestForPlanChangeCancellation()', function () {
  625. it('returns null when there is no pending change', function (ctx) {
  626. ctx.subscription.pendingChange = null
  627. const result = ctx.subscription.getRequestForPlanChangeCancellation()
  628. expect(result).to.be.null
  629. })
  630. it('returns null when pending change has no add-on changes', function (ctx) {
  631. // Same add-ons in pending change as current subscription
  632. ctx.subscription.pendingChange =
  633. new PaymentProviderSubscriptionChange({
  634. subscription: ctx.subscription,
  635. nextPlanCode: 'cheap-plan', // only plan change, no add-on change
  636. nextPlanName: 'Cheap Plan',
  637. nextPlanPrice: 5,
  638. nextAddOns: [ctx.addOn], // same add-on as current subscription
  639. })
  640. const result = ctx.subscription.getRequestForPlanChangeCancellation()
  641. expect(result).to.be.null
  642. })
  643. it('returns a change request preserving add-on removal when canceling plan change', function (ctx) {
  644. // Pending change has both plan downgrade AND add-on removal
  645. ctx.subscription.pendingChange =
  646. new PaymentProviderSubscriptionChange({
  647. subscription: ctx.subscription,
  648. nextPlanCode: 'cheap-plan',
  649. nextPlanName: 'Cheap Plan',
  650. nextPlanPrice: 5,
  651. nextAddOns: [], // add-on is being removed
  652. })
  653. const result = ctx.subscription.getRequestForPlanChangeCancellation()
  654. expect(result).to.not.be.null
  655. expect(result.timeframe).to.equal('term_end')
  656. expect(result.planCode).to.equal(ctx.subscription.planCode) // keep current plan
  657. expect(result.addOnUpdates).to.deep.equal([]) // preserve add-on removal
  658. })
  659. it('returns a change request preserving add-on addition when canceling plan change', function (ctx) {
  660. const {
  661. PaymentProviderSubscriptionAddOn,
  662. PaymentProviderSubscriptionAddOnUpdate,
  663. } = ctx.PaymentProviderEntities
  664. // Current subscription has one add-on
  665. // Pending change has plan downgrade AND a new add-on
  666. const newAddOn = new PaymentProviderSubscriptionAddOn({
  667. code: 'new-addon',
  668. name: 'New Add-On',
  669. quantity: 1,
  670. unitPrice: 3,
  671. })
  672. ctx.subscription.pendingChange =
  673. new PaymentProviderSubscriptionChange({
  674. subscription: ctx.subscription,
  675. nextPlanCode: 'cheap-plan',
  676. nextPlanName: 'Cheap Plan',
  677. nextPlanPrice: 5,
  678. nextAddOns: [ctx.addOn, newAddOn], // current add-on plus new one
  679. })
  680. const result = ctx.subscription.getRequestForPlanChangeCancellation()
  681. expect(result).to.not.be.null
  682. expect(result.timeframe).to.equal('term_end')
  683. expect(result.planCode).to.equal(ctx.subscription.planCode) // keep current plan
  684. expect(result.addOnUpdates).to.deep.equal([
  685. new PaymentProviderSubscriptionAddOnUpdate({
  686. code: ctx.addOn.code,
  687. quantity: ctx.addOn.quantity,
  688. unitPrice: ctx.addOn.unitPrice,
  689. }),
  690. new PaymentProviderSubscriptionAddOnUpdate({
  691. code: newAddOn.code,
  692. quantity: newAddOn.quantity,
  693. unitPrice: newAddOn.unitPrice,
  694. }),
  695. ])
  696. })
  697. it('removes additional-license add-on when canceling pending group plan upgrade', function (ctx) {
  698. const { MEMBERS_LIMIT_ADD_ON_CODE } = ctx.PaymentProviderEntities
  699. // Current: professional-annual (no add-ons, not a group plan)
  700. ctx.subscription.planCode = 'professional-annual'
  701. ctx.subscription.planName = 'Professional Annual'
  702. ctx.subscription.addOns = []
  703. // Pending: group_professional_10_educational + 5 additional-license
  704. const additionalLicenseAddOn =
  705. new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
  706. code: MEMBERS_LIMIT_ADD_ON_CODE,
  707. name: 'Additional License',
  708. quantity: 5,
  709. unitPrice: 10,
  710. })
  711. ctx.subscription.pendingChange =
  712. new PaymentProviderSubscriptionChange({
  713. subscription: ctx.subscription,
  714. nextPlanCode: 'group_professional_10_educational',
  715. nextPlanName: 'Group Professional Educational',
  716. nextPlanPrice: 100,
  717. nextAddOns: [additionalLicenseAddOn],
  718. })
  719. const result = ctx.subscription.getRequestForPlanChangeCancellation()
  720. // Should return a change request that keeps the current plan
  721. // but removes the additional-license add-on
  722. expect(result).to.not.be.null
  723. expect(result.timeframe).to.equal('term_end')
  724. expect(result.planCode).to.equal('professional-annual')
  725. expect(result.addOnUpdates).to.deep.equal([])
  726. })
  727. it('preserves non-members-limit add-ons when canceling pending group plan upgrade', function (ctx) {
  728. const { MEMBERS_LIMIT_ADD_ON_CODE } = ctx.PaymentProviderEntities
  729. // Current: professional-annual with AI add-on (not a group plan)
  730. ctx.subscription.planCode = 'professional-annual'
  731. ctx.subscription.planName = 'Professional Annual'
  732. const aiAddOn =
  733. new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
  734. code: AI_ADD_ON_CODE,
  735. name: 'AI Add-On',
  736. quantity: 1,
  737. unitPrice: 20,
  738. })
  739. ctx.subscription.addOns = [aiAddOn]
  740. // Pending: group_professional_10_educational + AI add-on + 5 additional-license
  741. const additionalLicenseAddOn =
  742. new ctx.PaymentProviderEntities.PaymentProviderSubscriptionAddOn({
  743. code: MEMBERS_LIMIT_ADD_ON_CODE,
  744. name: 'Additional License',
  745. quantity: 5,
  746. unitPrice: 10,
  747. })
  748. ctx.subscription.pendingChange =
  749. new PaymentProviderSubscriptionChange({
  750. subscription: ctx.subscription,
  751. nextPlanCode: 'group_professional_10_educational',
  752. nextPlanName: 'Group Professional Educational',
  753. nextPlanPrice: 100,
  754. nextAddOns: [aiAddOn, additionalLicenseAddOn],
  755. })
  756. const result = ctx.subscription.getRequestForPlanChangeCancellation()
  757. // Should return a change request that keeps the current plan and AI add-on
  758. // but removes the additional-license add-on
  759. expect(result).to.not.be.null
  760. expect(result.timeframe).to.equal('term_end')
  761. expect(result.planCode).to.equal('professional-annual')
  762. expect(result.addOnUpdates).to.deep.equal([
  763. new PaymentProviderSubscriptionAddOnUpdate({
  764. code: AI_ADD_ON_CODE,
  765. quantity: 1,
  766. unitPrice: 20,
  767. }),
  768. ])
  769. })
  770. })
  771. describe('with an add-on pending cancellation', function () {
  772. beforeEach(function (ctx) {
  773. ctx.subscription.pendingChange =
  774. new PaymentProviderSubscriptionChange({
  775. subscription: ctx.subscription,
  776. nextPlanCode: ctx.subscription.planCode,
  777. nextPlanName: ctx.subscription.planName,
  778. nextPlanPrice: ctx.subscription.planPrice,
  779. nextAddOns: [],
  780. })
  781. })
  782. describe('getRequestForAddOnReactivation()', function () {
  783. it('returns a change request', function (ctx) {
  784. const changeRequest =
  785. ctx.subscription.getRequestForAddOnReactivation(ctx.addOn.code)
  786. expect(changeRequest).to.deep.equal(
  787. new PaymentProviderSubscriptionChangeRequest({
  788. subscription: ctx.subscription,
  789. timeframe: 'term_end',
  790. planCode: ctx.subscription.pendingChange.nextPlanCode,
  791. addOnUpdates: [ctx.addOn.toAddOnUpdate()],
  792. })
  793. )
  794. })
  795. it('throws an AddOnNotPresentError if given the wrong add-on', function (ctx) {
  796. expect(() =>
  797. ctx.subscription.getRequestForAddOnReactivation('some-add-on')
  798. ).to.throw(Errors.AddOnNotPresentError)
  799. })
  800. })
  801. })
  802. })
  803. describe('without add-ons', function () {
  804. beforeEach(function (ctx) {
  805. const { PaymentProviderSubscription } = ctx.PaymentProviderEntities
  806. ctx.subscription = new PaymentProviderSubscription({
  807. id: 'subscription-id',
  808. userId: 'user-id',
  809. planCode: 'regular-plan',
  810. planName: 'My Plan',
  811. planPrice: 10,
  812. subtotal: 10.99,
  813. taxRate: 0.2,
  814. taxAmount: 2.4,
  815. total: 14.4,
  816. currency: 'USD',
  817. })
  818. })
  819. describe('hasAddOn()', function () {
  820. it('returns false for any add-on', function (ctx) {
  821. expect(ctx.subscription.hasAddOn('some-add-on')).to.be.false
  822. })
  823. })
  824. describe('getRequestForAddOnPurchase()', function () {
  825. it('returns a change request', function (ctx) {
  826. const {
  827. PaymentProviderSubscriptionChangeRequest,
  828. PaymentProviderSubscriptionAddOnUpdate,
  829. } = ctx.PaymentProviderEntities
  830. const changeRequest =
  831. ctx.subscription.getRequestForAddOnPurchase('some-add-on')
  832. expect(changeRequest).to.deep.equal(
  833. new PaymentProviderSubscriptionChangeRequest({
  834. subscription: ctx.subscription,
  835. timeframe: 'now',
  836. addOnUpdates: [
  837. new PaymentProviderSubscriptionAddOnUpdate({
  838. code: 'some-add-on',
  839. quantity: 1,
  840. }),
  841. ],
  842. })
  843. )
  844. })
  845. })
  846. describe('getRequestForAddOnRemoval()', function () {
  847. it('throws an AddOnNotPresentError', function (ctx) {
  848. expect(() =>
  849. ctx.subscription.getRequestForAddOnRemoval('some-add-on')
  850. ).to.throw(Errors.AddOnNotPresentError)
  851. })
  852. })
  853. describe('getRequestForAddOnReactivation()', function () {
  854. it('throws an AddOnNotPresentError', function (ctx) {
  855. expect(() =>
  856. ctx.subscription.getRequestForAddOnReactivation('some-add-on')
  857. ).to.throw(Errors.AddOnNotPresentError)
  858. })
  859. })
  860. })
  861. })
  862. describe('PaymentProviderSubscriptionChange', function () {
  863. describe('constructor', function () {
  864. it('rounds the amounts when calculating the taxes', function () {
  865. const subscription = new PaymentProviderSubscription({
  866. id: 'subscription-id',
  867. userId: 'user-id',
  868. planCode: 'premium-plan',
  869. planName: 'Premium plan',
  870. planPrice: 10,
  871. subtotal: 10,
  872. taxRate: 0.15,
  873. taxAmount: 1.5,
  874. currency: 'USD',
  875. total: 11.5,
  876. periodStart: new Date(),
  877. periodEnd: new Date(),
  878. collectionMethod: 'automatic',
  879. netTerms: 0,
  880. poNumber: '012345',
  881. termsAndConditions: 'T&C copy',
  882. })
  883. const change = new PaymentProviderSubscriptionChange({
  884. subscription,
  885. nextPlanCode: 'promotional-plan',
  886. nextPlanName: 'Promotial plan',
  887. nextPlanPrice: 8.99,
  888. nextAddOns: [],
  889. })
  890. expect(change.tax).to.equal(1.35)
  891. expect(change.total).to.equal(10.34)
  892. })
  893. })
  894. })
  895. })