payment-context.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import {
  2. useState,
  3. useEffect,
  4. useLayoutEffect,
  5. useMemo,
  6. useCallback,
  7. useRef,
  8. useContext,
  9. createContext,
  10. } from 'react'
  11. import { currencies, CurrencyCode, CurrencySymbol } from '../data/currency'
  12. import { useTranslation } from 'react-i18next'
  13. import getMeta from '../../../utils/meta'
  14. import * as eventTracking from '../../../infrastructure/event-tracking'
  15. import {
  16. PaymentContextValue,
  17. PricingFormState,
  18. } from './types/payment-context-value'
  19. import { Plan } from '../../../../../types/subscription/plan'
  20. import {
  21. RecurlyOptions,
  22. SubscriptionPricingStateTax,
  23. } from 'recurly__recurly-js'
  24. import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription'
  25. function usePayment({ publicKey }: RecurlyOptions) {
  26. const { t } = useTranslation()
  27. const plan: Plan = getMeta('ol-plan')
  28. const initialCountry: PricingFormState['country'] = getMeta(
  29. 'ol-countryCode',
  30. ''
  31. )
  32. const initialCouponCode: PricingFormState['coupon'] = getMeta(
  33. 'ol-couponCode',
  34. ''
  35. )
  36. const initiallySelectedCurrencyCode: CurrencyCode = getMeta(
  37. 'ol-recommendedCurrency'
  38. )
  39. const planCode: string = getMeta('ol-planCode')
  40. const [planName, setPlanName] = useState(plan.name)
  41. const [recurlyLoading, setRecurlyLoading] = useState(true)
  42. const [recurlyLoadError, setRecurlyLoadError] = useState(false)
  43. const [recurlyPrice, setRecurlyPrice] = useState<{
  44. subtotal: string
  45. plan: string
  46. addons: string
  47. setup_fee: string
  48. discount: string
  49. tax: string
  50. total: string
  51. }>()
  52. const [monthlyBilling, setMonthlyBilling] = useState<boolean>()
  53. const [taxes, setTaxes] = useState<SubscriptionPricingStateTax[]>([])
  54. const [coupon, setCoupon] = useState<{
  55. discountMonths?: number
  56. discountRate?: number
  57. singleUse: boolean
  58. normalPrice: number
  59. name: string
  60. normalPriceWithoutTax: number
  61. }>()
  62. const [couponError, setCouponError] = useState('')
  63. const [trialLength, setTrialLength] = useState<number>()
  64. const [currencyCode, setCurrencyCode] = useState(
  65. initiallySelectedCurrencyCode
  66. )
  67. const [pricingFormState, setPricingFormState] = useState<PricingFormState>({
  68. first_name: '',
  69. last_name: '',
  70. postal_code: '',
  71. address1: '',
  72. address2: '',
  73. state: '',
  74. city: '',
  75. company: '',
  76. vat_number: '',
  77. country: initialCountry,
  78. coupon: initialCouponCode,
  79. })
  80. const pricing = useRef<SubscriptionPricingInstanceCustom>()
  81. const limitedCurrencyCodes = Array.from(
  82. new Set<CurrencyCode>([initiallySelectedCurrencyCode, 'USD', 'EUR', 'GBP'])
  83. )
  84. const limitedCurrencies = limitedCurrencyCodes.reduce((prev, cur) => {
  85. return { ...prev, [cur]: currencies[cur] }
  86. }, {} as Partial<typeof currencies>)
  87. const currencySymbol = limitedCurrencies[currencyCode] as CurrencySymbol
  88. useLayoutEffect(() => {
  89. if (typeof recurly === 'undefined' || !recurly) {
  90. setRecurlyLoadError(true)
  91. return
  92. }
  93. eventTracking.sendMB('payment-page-view', { plan: planCode })
  94. eventTracking.send(
  95. 'subscription-funnel',
  96. 'subscription-form-viewed',
  97. planCode
  98. )
  99. recurly.configure({ publicKey })
  100. pricing.current =
  101. recurly.Pricing.Subscription() as SubscriptionPricingInstanceCustom
  102. const setupPricing = () => {
  103. setRecurlyLoading(true)
  104. pricing.current
  105. ?.plan(planCode, { quantity: 1 })
  106. .address({
  107. first_name: '',
  108. last_name: '',
  109. country: initialCountry,
  110. })
  111. .tax({ tax_code: 'digital', vat_number: '' })
  112. .currency(initiallySelectedCurrencyCode)
  113. .coupon(initialCouponCode)
  114. .catch(function (err) {
  115. if (
  116. initiallySelectedCurrencyCode !== 'USD' &&
  117. err.name === 'invalid-currency'
  118. ) {
  119. setCurrencyCode('USD')
  120. setupPricing()
  121. } else if (err.name === 'api-error' && err.code === 'not-found') {
  122. // not-found here should refer to the coupon code, plan_code should be valid
  123. setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
  124. } else {
  125. // Bail out on other errors, form state will not be correct
  126. setRecurlyLoadError(true)
  127. throw err
  128. }
  129. })
  130. .done(() => {
  131. setRecurlyLoading(false)
  132. })
  133. }
  134. setupPricing()
  135. }, [
  136. initialCountry,
  137. initialCouponCode,
  138. initiallySelectedCurrencyCode,
  139. planCode,
  140. publicKey,
  141. t,
  142. ])
  143. useEffect(() => {
  144. pricing.current?.on('change', function () {
  145. if (!pricing.current) return
  146. const planName = pricing.current.items.plan?.name
  147. if (planName) {
  148. setPlanName(planName)
  149. }
  150. const trialLength = pricing.current.items.plan?.trial?.length
  151. setTrialLength(trialLength)
  152. const recurlyPrice = trialLength
  153. ? pricing.current.price.next
  154. : pricing.current.price.now
  155. setRecurlyPrice(recurlyPrice)
  156. const monthlyBilling = pricing.current.items.plan?.period.length === 1
  157. setMonthlyBilling(monthlyBilling)
  158. setTaxes(pricing.current.price.taxes)
  159. const couponData = (() => {
  160. if (pricing.current.items.coupon?.discount.type === 'percent') {
  161. const coupon = pricing.current.items.coupon
  162. const basePrice = parseInt(pricing.current.price.base.plan.unit, 10)
  163. const discountData =
  164. coupon.applies_for_months > 0 && coupon.discount.rate
  165. ? {
  166. discountMonths: coupon.applies_for_months,
  167. discountRate: coupon.discount.rate * 100,
  168. }
  169. : {}
  170. const couponData = {
  171. singleUse: coupon.single_use,
  172. normalPrice: basePrice,
  173. name: coupon.name,
  174. normalPriceWithoutTax: basePrice,
  175. ...discountData,
  176. }
  177. if (pricing.current.price.taxes[0]?.rate) {
  178. couponData.normalPrice +=
  179. basePrice * parseFloat(pricing.current.price.taxes[0].rate)
  180. }
  181. return couponData
  182. }
  183. })()
  184. setCoupon(couponData)
  185. })
  186. }, [])
  187. const addCoupon = useCallback(
  188. (coupon: PricingFormState['coupon']) => {
  189. setRecurlyLoading(true)
  190. setCouponError('')
  191. pricing.current
  192. ?.coupon(coupon)
  193. .catch(function (err) {
  194. if (err.name === 'api-error' && err.code === 'not-found') {
  195. setCouponError(t('coupon_code_is_not_valid_for_selected_plan'))
  196. } else {
  197. setCouponError(
  198. t('an_error_occurred_when_verifying_the_coupon_code')
  199. )
  200. throw err
  201. }
  202. })
  203. .done(() => {
  204. setRecurlyLoading(false)
  205. })
  206. },
  207. [t]
  208. )
  209. const updateCountry = useCallback(
  210. (country: PricingFormState['country']) => {
  211. setRecurlyLoading(true)
  212. pricing.current
  213. ?.address({
  214. country,
  215. first_name: pricingFormState.first_name,
  216. last_name: pricingFormState.last_name,
  217. })
  218. .done(() => {
  219. setRecurlyLoading(false)
  220. })
  221. },
  222. [pricingFormState.first_name, pricingFormState.last_name]
  223. )
  224. const applyVatNumber = useCallback(
  225. (vatNumber: PricingFormState['vat_number']) => {
  226. setRecurlyLoading(true)
  227. pricing.current
  228. ?.tax({ tax_code: 'digital', vat_number: vatNumber })
  229. .done(() => {
  230. setRecurlyLoading(false)
  231. })
  232. },
  233. []
  234. )
  235. const changeCurrency = useCallback(
  236. (newCurrency: CurrencyCode) => {
  237. setRecurlyLoading(true)
  238. setCurrencyCode(newCurrency)
  239. pricing.current
  240. ?.currency(newCurrency)
  241. .catch(function (err) {
  242. if (currencyCode !== 'USD' && err.name === 'invalid-currency') {
  243. setCurrencyCode('USD')
  244. } else {
  245. throw err
  246. }
  247. })
  248. .done(() => {
  249. setRecurlyLoading(false)
  250. })
  251. },
  252. [currencyCode]
  253. )
  254. const value = useMemo<PaymentContextValue>(
  255. () => ({
  256. currencyCode,
  257. setCurrencyCode,
  258. currencySymbol,
  259. limitedCurrencies,
  260. pricingFormState,
  261. setPricingFormState,
  262. plan,
  263. planCode,
  264. planName,
  265. pricing,
  266. recurlyLoading,
  267. recurlyLoadError,
  268. recurlyPrice,
  269. monthlyBilling,
  270. taxes,
  271. coupon,
  272. couponError,
  273. trialLength,
  274. addCoupon,
  275. applyVatNumber,
  276. changeCurrency,
  277. updateCountry,
  278. }),
  279. [
  280. currencyCode,
  281. setCurrencyCode,
  282. currencySymbol,
  283. limitedCurrencies,
  284. pricingFormState,
  285. setPricingFormState,
  286. plan,
  287. planCode,
  288. planName,
  289. pricing,
  290. recurlyLoading,
  291. recurlyLoadError,
  292. recurlyPrice,
  293. monthlyBilling,
  294. taxes,
  295. coupon,
  296. couponError,
  297. trialLength,
  298. addCoupon,
  299. applyVatNumber,
  300. changeCurrency,
  301. updateCountry,
  302. ]
  303. )
  304. return { value }
  305. }
  306. export const PaymentContext = createContext<PaymentContextValue | undefined>(
  307. undefined
  308. )
  309. type PaymentProviderProps = {
  310. publicKey: string
  311. children: React.ReactNode
  312. }
  313. export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) {
  314. const { value } = usePayment({ publicKey })
  315. return <PaymentContext.Provider value={value} {...props} />
  316. }
  317. export function usePaymentContext() {
  318. const context = useContext(PaymentContext)
  319. if (!context) {
  320. throw new Error('PaymentContext is only available inside PaymentProvider')
  321. }
  322. return context
  323. }