RecurlyEntities.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. // @ts-check
  2. const OError = require('@overleaf/o-error')
  3. const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
  4. const PlansLocator = require('./PlansLocator')
  5. const SubscriptionHelper = require('./SubscriptionHelper')
  6. const AI_ADD_ON_CODE = 'assistant'
  7. const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license'
  8. const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
  9. class RecurlySubscription {
  10. /**
  11. * @param {object} props
  12. * @param {string} props.id
  13. * @param {string} props.userId
  14. * @param {string} props.planCode
  15. * @param {string} props.planName
  16. * @param {number} props.planPrice
  17. * @param {RecurlySubscriptionAddOn[]} [props.addOns]
  18. * @param {number} props.subtotal
  19. * @param {number} [props.taxRate]
  20. * @param {number} [props.taxAmount]
  21. * @param {string} props.currency
  22. * @param {number} props.total
  23. * @param {Date} props.periodStart
  24. * @param {Date} props.periodEnd
  25. * @param {string} props.collectionMethod
  26. * @param {RecurlySubscriptionChange} [props.pendingChange]
  27. */
  28. constructor(props) {
  29. this.id = props.id
  30. this.userId = props.userId
  31. this.planCode = props.planCode
  32. this.planName = props.planName
  33. this.planPrice = props.planPrice
  34. this.addOns = props.addOns ?? []
  35. this.subtotal = props.subtotal
  36. this.taxRate = props.taxRate ?? 0
  37. this.taxAmount = props.taxAmount ?? 0
  38. this.currency = props.currency
  39. this.total = props.total
  40. this.periodStart = props.periodStart
  41. this.periodEnd = props.periodEnd
  42. this.collectionMethod = props.collectionMethod
  43. this.pendingChange = props.pendingChange ?? null
  44. }
  45. /**
  46. * Returns whether this subscription currently has the given add-on
  47. *
  48. * @param {string} code
  49. * @return {boolean}
  50. */
  51. hasAddOn(code) {
  52. return this.addOns.some(addOn => addOn.code === code)
  53. }
  54. /**
  55. * Returns whether this subscription is a standalone AI add-on subscription
  56. *
  57. * @return {boolean}
  58. */
  59. isStandaloneAiAddOn() {
  60. return isStandaloneAiAddOnPlanCode(this.planCode)
  61. }
  62. /**
  63. * Returns whether this subcription will have the given add-on next billing
  64. * period.
  65. *
  66. * There are two cases: either the subscription already has the add-on and
  67. * won't change next period, or the subscription will change next period and
  68. * the change includes the add-on.
  69. *
  70. * @param {string} code
  71. * @return {boolean}
  72. */
  73. hasAddOnNextPeriod(code) {
  74. if (this.pendingChange != null) {
  75. return this.pendingChange.nextAddOns.some(addOn => addOn.code === code)
  76. } else {
  77. return this.hasAddOn(code)
  78. }
  79. }
  80. /**
  81. * Change this subscription's plan
  82. *
  83. * @return {RecurlySubscriptionChangeRequest}
  84. */
  85. getRequestForPlanChange(planCode) {
  86. const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode)
  87. if (currentPlan == null) {
  88. throw new OError('Unable to find plan in settings', {
  89. planCode: this.planCode,
  90. })
  91. }
  92. const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
  93. if (newPlan == null) {
  94. throw new OError('Unable to find plan in settings', { planCode })
  95. }
  96. const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
  97. currentPlan,
  98. newPlan
  99. )
  100. const changeRequest = new RecurlySubscriptionChangeRequest({
  101. subscription: this,
  102. timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
  103. planCode,
  104. })
  105. // Carry the AI add-on to the new plan if applicable
  106. if (
  107. this.isStandaloneAiAddOn() ||
  108. (!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
  109. (shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
  110. ) {
  111. const addOnUpdate = new RecurlySubscriptionAddOnUpdate({
  112. code: AI_ADD_ON_CODE,
  113. quantity: 1,
  114. })
  115. changeRequest.addOnUpdates = [addOnUpdate]
  116. }
  117. return changeRequest
  118. }
  119. /**
  120. * Purchase an add-on on this subscription
  121. *
  122. * @param {string} code
  123. * @param {number} [quantity]
  124. * @param {number} [unitPrice]
  125. * @return {RecurlySubscriptionChangeRequest} - the change request to send to
  126. * Recurly
  127. *
  128. * @throws {DuplicateAddOnError} if the add-on is already present on the subscription
  129. */
  130. getRequestForAddOnPurchase(code, quantity = 1, unitPrice) {
  131. if (this.hasAddOn(code)) {
  132. throw new DuplicateAddOnError('Subscription already has add-on', {
  133. subscriptionId: this.id,
  134. addOnCode: code,
  135. })
  136. }
  137. const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
  138. addOnUpdates.push(
  139. new RecurlySubscriptionAddOnUpdate({ code, quantity, unitPrice })
  140. )
  141. return new RecurlySubscriptionChangeRequest({
  142. subscription: this,
  143. timeframe: 'now',
  144. addOnUpdates,
  145. })
  146. }
  147. /**
  148. * Update an add-on on this subscription
  149. *
  150. * @param {string} code
  151. * @param {number} quantity
  152. * @return {RecurlySubscriptionChangeRequest} - the change request to send to
  153. * Recurly
  154. *
  155. * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
  156. */
  157. getRequestForAddOnUpdate(code, quantity) {
  158. if (!this.hasAddOn(code)) {
  159. throw new AddOnNotPresentError(
  160. 'Subscription does not have add-on to update',
  161. {
  162. subscriptionId: this.id,
  163. addOnCode: code,
  164. }
  165. )
  166. }
  167. const addOnUpdates = this.addOns.map(addOn => {
  168. const update = addOn.toAddOnUpdate()
  169. if (update.code === code) {
  170. update.quantity = quantity
  171. }
  172. return update
  173. })
  174. return new RecurlySubscriptionChangeRequest({
  175. subscription: this,
  176. timeframe: 'now',
  177. addOnUpdates,
  178. })
  179. }
  180. /**
  181. * Remove an add-on from this subscription
  182. *
  183. * @param {string} code
  184. * @return {RecurlySubscriptionChangeRequest}
  185. *
  186. * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
  187. */
  188. getRequestForAddOnRemoval(code) {
  189. if (!this.hasAddOn(code)) {
  190. throw new AddOnNotPresentError(
  191. 'Subscription does not have add-on to remove',
  192. {
  193. subscriptionId: this.id,
  194. addOnCode: code,
  195. }
  196. )
  197. }
  198. const addOnUpdates = this.addOns
  199. .filter(addOn => addOn.code !== code)
  200. .map(addOn => addOn.toAddOnUpdate())
  201. return new RecurlySubscriptionChangeRequest({
  202. subscription: this,
  203. timeframe: 'term_end',
  204. addOnUpdates,
  205. })
  206. }
  207. /**
  208. * Upgrade group plan with the plan code provided
  209. *
  210. * @param {string} newPlanCode
  211. * @return {RecurlySubscriptionChangeRequest}
  212. */
  213. getRequestForGroupPlanUpgrade(newPlanCode) {
  214. // Ensure all the existing add-ons are added to the new plan
  215. const addOns = this.addOns.map(
  216. addOn =>
  217. new RecurlySubscriptionAddOnUpdate({
  218. code: addOn.code,
  219. quantity: addOn.quantity,
  220. })
  221. )
  222. return new RecurlySubscriptionChangeRequest({
  223. subscription: this,
  224. timeframe: 'now',
  225. addOnUpdates: addOns,
  226. planCode: newPlanCode,
  227. })
  228. }
  229. /**
  230. * Returns whether this subscription is manually collected
  231. *
  232. * @return {boolean}
  233. */
  234. get isCollectionMethodManual() {
  235. return this.collectionMethod === 'manual'
  236. }
  237. }
  238. /**
  239. * An add-on attached to a subscription
  240. */
  241. class RecurlySubscriptionAddOn {
  242. /**
  243. * @param {object} props
  244. * @param {string} props.code
  245. * @param {string} props.name
  246. * @param {number} props.quantity
  247. * @param {number} props.unitPrice
  248. */
  249. constructor(props) {
  250. this.code = props.code
  251. this.name = props.name
  252. this.quantity = props.quantity
  253. this.unitPrice = props.unitPrice
  254. this.preTaxTotal = this.quantity * this.unitPrice
  255. }
  256. /**
  257. * Return an add-on update that doesn't modify the add-on
  258. */
  259. toAddOnUpdate() {
  260. return new RecurlySubscriptionAddOnUpdate({
  261. code: this.code,
  262. quantity: this.quantity,
  263. unitPrice: this.unitPrice,
  264. })
  265. }
  266. }
  267. class RecurlySubscriptionChangeRequest {
  268. /**
  269. * @param {object} props
  270. * @param {RecurlySubscription} props.subscription
  271. * @param {"now" | "term_end"} props.timeframe
  272. * @param {string} [props.planCode]
  273. * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
  274. */
  275. constructor(props) {
  276. if (props.planCode == null && props.addOnUpdates == null) {
  277. throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
  278. }
  279. this.subscription = props.subscription
  280. this.timeframe = props.timeframe
  281. this.planCode = props.planCode ?? null
  282. this.addOnUpdates = props.addOnUpdates ?? null
  283. }
  284. }
  285. class RecurlySubscriptionAddOnUpdate {
  286. /**
  287. * @param {object} props
  288. * @param {string} props.code
  289. * @param {number} [props.quantity]
  290. * @param {number} [props.unitPrice]
  291. */
  292. constructor(props) {
  293. this.code = props.code
  294. this.quantity = props.quantity ?? null
  295. this.unitPrice = props.unitPrice ?? null
  296. }
  297. }
  298. class RecurlySubscriptionChange {
  299. /**
  300. * @param {object} props
  301. * @param {RecurlySubscription} props.subscription
  302. * @param {string} props.nextPlanCode
  303. * @param {string} props.nextPlanName
  304. * @param {number} props.nextPlanPrice
  305. * @param {RecurlySubscriptionAddOn[]} props.nextAddOns
  306. * @param {RecurlyImmediateCharge} [props.immediateCharge]
  307. */
  308. constructor(props) {
  309. this.subscription = props.subscription
  310. this.nextPlanCode = props.nextPlanCode
  311. this.nextPlanName = props.nextPlanName
  312. this.nextPlanPrice = props.nextPlanPrice
  313. this.nextAddOns = props.nextAddOns
  314. this.immediateCharge =
  315. props.immediateCharge ??
  316. new RecurlyImmediateCharge({ subtotal: 0, tax: 0, total: 0, discount: 0 })
  317. this.subtotal = this.nextPlanPrice
  318. for (const addOn of this.nextAddOns) {
  319. this.subtotal += addOn.preTaxTotal
  320. }
  321. this.tax = Math.round(this.subtotal * 100 * this.subscription.taxRate) / 100
  322. this.total = this.subtotal + this.tax
  323. }
  324. getAddOn(addOnCode) {
  325. return this.nextAddOns.find(addOn => addOn.code === addOnCode)
  326. }
  327. }
  328. class PaypalPaymentMethod {
  329. toString() {
  330. return 'Paypal'
  331. }
  332. }
  333. class CreditCardPaymentMethod {
  334. /**
  335. * @param {object} props
  336. * @param {string} props.cardType
  337. * @param {string} props.lastFour
  338. */
  339. constructor(props) {
  340. this.cardType = props.cardType
  341. this.lastFour = props.lastFour
  342. }
  343. toString() {
  344. return `${this.cardType} **** ${this.lastFour}`
  345. }
  346. }
  347. class RecurlyImmediateCharge {
  348. /**
  349. * @param {object} props
  350. * @param {number} props.subtotal
  351. * @param {number} props.tax
  352. * @param {number} props.total
  353. * @param {number} props.discount
  354. */
  355. constructor(props) {
  356. this.subtotal = props.subtotal
  357. this.tax = props.tax
  358. this.total = props.total
  359. this.discount = props.discount
  360. }
  361. }
  362. /**
  363. * An add-on configuration, independent of any subscription
  364. */
  365. class RecurlyAddOn {
  366. /**
  367. * @param {object} props
  368. * @param {string} props.code
  369. * @param {string} props.name
  370. */
  371. constructor(props) {
  372. this.code = props.code
  373. this.name = props.name
  374. }
  375. }
  376. /**
  377. * A plan configuration
  378. */
  379. class RecurlyPlan {
  380. /**
  381. * @param {object} props
  382. * @param {string} props.code
  383. * @param {string} props.name
  384. */
  385. constructor(props) {
  386. this.code = props.code
  387. this.name = props.name
  388. }
  389. }
  390. /**
  391. * Returns whether the given plan code is a standalone AI plan
  392. *
  393. * @param {string} planCode
  394. */
  395. function isStandaloneAiAddOnPlanCode(planCode) {
  396. return STANDALONE_AI_ADD_ON_CODES.includes(planCode)
  397. }
  398. module.exports = {
  399. AI_ADD_ON_CODE,
  400. MEMBERS_LIMIT_ADD_ON_CODE,
  401. STANDALONE_AI_ADD_ON_CODES,
  402. RecurlySubscription,
  403. RecurlySubscriptionAddOn,
  404. RecurlySubscriptionChange,
  405. RecurlySubscriptionChangeRequest,
  406. RecurlySubscriptionAddOnUpdate,
  407. PaypalPaymentMethod,
  408. CreditCardPaymentMethod,
  409. RecurlyAddOn,
  410. RecurlyPlan,
  411. isStandaloneAiAddOnPlanCode,
  412. RecurlyImmediateCharge,
  413. }