RecurlyEntities.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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 {Date} props.createdAt
  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.createdAt = props.createdAt
  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. /**
  231. * An add-on attached to a subscription
  232. */
  233. class RecurlySubscriptionAddOn {
  234. /**
  235. * @param {object} props
  236. * @param {string} props.code
  237. * @param {string} props.name
  238. * @param {number} props.quantity
  239. * @param {number} props.unitPrice
  240. */
  241. constructor(props) {
  242. this.code = props.code
  243. this.name = props.name
  244. this.quantity = props.quantity
  245. this.unitPrice = props.unitPrice
  246. this.preTaxTotal = this.quantity * this.unitPrice
  247. }
  248. /**
  249. * Return an add-on update that doesn't modify the add-on
  250. */
  251. toAddOnUpdate() {
  252. return new RecurlySubscriptionAddOnUpdate({
  253. code: this.code,
  254. quantity: this.quantity,
  255. unitPrice: this.unitPrice,
  256. })
  257. }
  258. }
  259. class RecurlySubscriptionChangeRequest {
  260. /**
  261. * @param {object} props
  262. * @param {RecurlySubscription} props.subscription
  263. * @param {"now" | "term_end"} props.timeframe
  264. * @param {string} [props.planCode]
  265. * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
  266. */
  267. constructor(props) {
  268. if (props.planCode == null && props.addOnUpdates == null) {
  269. throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
  270. }
  271. this.subscription = props.subscription
  272. this.timeframe = props.timeframe
  273. this.planCode = props.planCode ?? null
  274. this.addOnUpdates = props.addOnUpdates ?? null
  275. }
  276. }
  277. class RecurlySubscriptionAddOnUpdate {
  278. /**
  279. * @param {object} props
  280. * @param {string} props.code
  281. * @param {number} [props.quantity]
  282. * @param {number} [props.unitPrice]
  283. */
  284. constructor(props) {
  285. this.code = props.code
  286. this.quantity = props.quantity ?? null
  287. this.unitPrice = props.unitPrice ?? null
  288. }
  289. }
  290. class RecurlySubscriptionChange {
  291. /**
  292. * @param {object} props
  293. * @param {RecurlySubscription} props.subscription
  294. * @param {string} props.nextPlanCode
  295. * @param {string} props.nextPlanName
  296. * @param {number} props.nextPlanPrice
  297. * @param {RecurlySubscriptionAddOn[]} props.nextAddOns
  298. * @param {RecurlyImmediateCharge} [props.immediateCharge]
  299. */
  300. constructor(props) {
  301. this.subscription = props.subscription
  302. this.nextPlanCode = props.nextPlanCode
  303. this.nextPlanName = props.nextPlanName
  304. this.nextPlanPrice = props.nextPlanPrice
  305. this.nextAddOns = props.nextAddOns
  306. this.immediateCharge =
  307. props.immediateCharge ??
  308. new RecurlyImmediateCharge({ subtotal: 0, tax: 0, total: 0 })
  309. this.subtotal = this.nextPlanPrice
  310. for (const addOn of this.nextAddOns) {
  311. this.subtotal += addOn.preTaxTotal
  312. }
  313. this.tax = Math.round(this.subtotal * 100 * this.subscription.taxRate) / 100
  314. this.total = this.subtotal + this.tax
  315. }
  316. getAddOn(addOnCode) {
  317. return this.nextAddOns.find(addOn => addOn.code === addOnCode)
  318. }
  319. }
  320. class PaypalPaymentMethod {
  321. toString() {
  322. return 'Paypal'
  323. }
  324. }
  325. class CreditCardPaymentMethod {
  326. /**
  327. * @param {object} props
  328. * @param {string} props.cardType
  329. * @param {string} props.lastFour
  330. */
  331. constructor(props) {
  332. this.cardType = props.cardType
  333. this.lastFour = props.lastFour
  334. }
  335. toString() {
  336. return `${this.cardType} **** ${this.lastFour}`
  337. }
  338. }
  339. class RecurlyImmediateCharge {
  340. /**
  341. * @param {object} props
  342. * @param {number} props.subtotal
  343. * @param {number} props.tax
  344. * @param {number} props.total
  345. */
  346. constructor(props) {
  347. this.subtotal = props.subtotal
  348. this.tax = props.tax
  349. this.total = props.total
  350. }
  351. }
  352. /**
  353. * An add-on configuration, independent of any subscription
  354. */
  355. class RecurlyAddOn {
  356. /**
  357. * @param {object} props
  358. * @param {string} props.code
  359. * @param {string} props.name
  360. */
  361. constructor(props) {
  362. this.code = props.code
  363. this.name = props.name
  364. }
  365. }
  366. /**
  367. * A plan configuration
  368. */
  369. class RecurlyPlan {
  370. /**
  371. * @param {object} props
  372. * @param {string} props.code
  373. * @param {string} props.name
  374. */
  375. constructor(props) {
  376. this.code = props.code
  377. this.name = props.name
  378. }
  379. }
  380. /**
  381. * Returns whether the given plan code is a standalone AI plan
  382. *
  383. * @param {string} planCode
  384. */
  385. function isStandaloneAiAddOnPlanCode(planCode) {
  386. return STANDALONE_AI_ADD_ON_CODES.includes(planCode)
  387. }
  388. module.exports = {
  389. AI_ADD_ON_CODE,
  390. MEMBERS_LIMIT_ADD_ON_CODE,
  391. RecurlySubscription,
  392. RecurlySubscriptionAddOn,
  393. RecurlySubscriptionChange,
  394. RecurlySubscriptionChangeRequest,
  395. RecurlySubscriptionAddOnUpdate,
  396. PaypalPaymentMethod,
  397. CreditCardPaymentMethod,
  398. RecurlyAddOn,
  399. RecurlyPlan,
  400. isStandaloneAiAddOnPlanCode,
  401. RecurlyImmediateCharge,
  402. }