CreditTopUp.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import HCaptcha from '@hcaptcha/react-hcaptcha'
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { Elements } from '@stripe/react-stripe-js'
  4. import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js'
  5. import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants'
  6. import { useQueryClient } from '@tanstack/react-query'
  7. import { useDebounce } from '@uidotdev/usehooks'
  8. import { AlertCircle, Info } from 'lucide-react'
  9. import { useTheme } from 'next-themes'
  10. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  11. import { SubmitHandler, useForm } from 'react-hook-form'
  12. import { toast } from 'sonner'
  13. import {
  14. Alert,
  15. AlertDescription,
  16. AlertTitle,
  17. Button,
  18. Dialog,
  19. DialogContent,
  20. DialogDescription,
  21. DialogFooter,
  22. DialogHeader,
  23. DialogSection,
  24. DialogSectionSeparator,
  25. DialogTitle,
  26. DialogTrigger,
  27. Form,
  28. FormField,
  29. Input,
  30. } from 'ui'
  31. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  32. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  33. import { z } from 'zod'
  34. import type { PaymentMethodElementRef } from '../../Billing/Payment/PaymentMethods/NewPaymentMethodElement'
  35. import PaymentMethodSelection from './Subscription/PaymentMethodSelection'
  36. import { ChargeBreakdown } from '@/components/interfaces/Billing/ChargeBreakdown'
  37. import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
  38. import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation'
  39. import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils'
  40. import { SupportLink } from '@/components/interfaces/Support/SupportLink'
  41. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  42. import { useOrganizationCreditTopUpMutation } from '@/data/organizations/organization-credit-top-up-mutation'
  43. import { useCreditTopUpPreview } from '@/data/organizations/organization-credit-top-up-preview'
  44. import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
  45. import { subscriptionKeys } from '@/data/subscriptions/keys'
  46. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  47. import { STRIPE_PUBLIC_KEY } from '@/lib/constants'
  48. import { formatCurrency } from '@/lib/helpers'
  49. const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
  50. const FORM_ID = 'credit-top-up'
  51. const MIN_TOP_UP_AMOUNT = 300
  52. const MAX_TOP_UP_AMOUNT = 2000
  53. const FormSchema = z.object({
  54. amount: z.coerce
  55. .number()
  56. .gte(MIN_TOP_UP_AMOUNT, `Amount must be between $${MIN_TOP_UP_AMOUNT} - $${MAX_TOP_UP_AMOUNT}.`)
  57. .lte(MAX_TOP_UP_AMOUNT, `Amount must be between $${MIN_TOP_UP_AMOUNT} - $${MAX_TOP_UP_AMOUNT}.`)
  58. .int('Amount must be a whole number.'),
  59. paymentMethod: z.string(),
  60. })
  61. type CreditTopUpForm = z.infer<typeof FormSchema>
  62. export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
  63. const { resolvedTheme } = useTheme()
  64. const queryClient = useQueryClient()
  65. const paymentMethodSelectionRef = useRef<{
  66. createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
  67. validateBillingProfile: () => Promise<boolean>
  68. }>(null)
  69. const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
  70. PermissionAction.BILLING_WRITE,
  71. 'stripe.subscriptions'
  72. )
  73. const {
  74. mutateAsync: topUpCredits,
  75. isPending: executingTopUp,
  76. error: errorInitiatingTopUp,
  77. } = useOrganizationCreditTopUpMutation({})
  78. const form = useForm<CreditTopUpForm>({
  79. resolver: zodResolver(FormSchema as any),
  80. defaultValues: {
  81. amount: 300,
  82. paymentMethod: '',
  83. },
  84. })
  85. const [topUpModalVisible, setTopUpModalVisible] = useState(false)
  86. const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
  87. const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
  88. const [latestAddress, setLatestAddress] = useState<CustomerAddress>()
  89. const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
  90. const billingAddress = useAsDefaultBillingAddress ? latestAddress : undefined
  91. const billingTaxId = useAsDefaultBillingAddress ? latestTaxId : null
  92. const debouncedAddress = useDebounce(billingAddress, 1000)
  93. const debouncedTaxId = useDebounce(billingTaxId, 1000)
  94. const watchedAmount = form.watch('amount')
  95. const debouncedAmount = useDebounce(watchedAmount, 1000)
  96. const parsedAmount = Number(debouncedAmount)
  97. const validAmount =
  98. !Number.isNaN(parsedAmount) &&
  99. Number.isInteger(parsedAmount) &&
  100. parsedAmount >= MIN_TOP_UP_AMOUNT &&
  101. parsedAmount <= MAX_TOP_UP_AMOUNT
  102. ? parsedAmount
  103. : undefined
  104. const isPreviewStale =
  105. watchedAmount !== debouncedAmount ||
  106. billingAddress !== debouncedAddress ||
  107. billingTaxId !== debouncedTaxId
  108. const handleAddressChange = useCallback((address: CustomerAddress) => {
  109. setLatestAddress(address)
  110. }, [])
  111. const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => {
  112. setLatestTaxId(taxId)
  113. }, [])
  114. const {
  115. data: creditPreview,
  116. isFetching: creditPreviewIsFetching,
  117. isSuccess: creditPreviewInitialized,
  118. } = useCreditTopUpPreview(
  119. {
  120. slug,
  121. amount: validAmount,
  122. address: debouncedAddress,
  123. taxId: debouncedTaxId ?? undefined,
  124. },
  125. { enabled: topUpModalVisible && !!validAmount }
  126. )
  127. const [captchaToken, setCaptchaToken] = useState<string | null>(null)
  128. const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
  129. const captchaRefCallback = useCallback((node: any) => {
  130. setCaptchaRef(node)
  131. }, [])
  132. const resetCaptcha = () => {
  133. setCaptchaToken(null)
  134. captchaRef?.resetCaptcha()
  135. }
  136. const initHcaptcha = async () => {
  137. if (topUpModalVisible && captchaRef) {
  138. let token = captchaToken
  139. try {
  140. if (!token) {
  141. const captchaResponse = await captchaRef.execute({ async: true })
  142. token = captchaResponse?.response ?? null
  143. setCaptchaToken(token)
  144. return token
  145. }
  146. } catch (error) {
  147. return token
  148. }
  149. return token
  150. }
  151. }
  152. useEffect(() => {
  153. initHcaptcha()
  154. }, [topUpModalVisible, captchaRef])
  155. const [paymentIntentSecret, setPaymentIntentSecret] = useState('')
  156. const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState<PaymentIntentResult>()
  157. const onSubmit: SubmitHandler<CreditTopUpForm> = async ({ amount }) => {
  158. setPaymentIntentConfirmation(undefined)
  159. const token = await initHcaptcha()
  160. const isValid = await paymentMethodSelectionRef.current?.validateBillingProfile()
  161. if (!isValid) return
  162. const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod()
  163. if (!paymentMethodResult) {
  164. return
  165. }
  166. await topUpCredits(
  167. {
  168. slug,
  169. amount,
  170. payment_method_id: paymentMethodResult.paymentMethod.id,
  171. hcaptchaToken: token,
  172. address: paymentMethodResult.address,
  173. tax_id: paymentMethodResult.taxId ?? undefined,
  174. billing_name: paymentMethodResult.customerName,
  175. },
  176. {
  177. onSuccess: (data) => {
  178. if (data.status === 'succeeded') {
  179. onSuccessfulPayment()
  180. } else {
  181. setPaymentIntentSecret(data.payment_intent_secret || '')
  182. }
  183. resetCaptcha()
  184. },
  185. }
  186. )
  187. }
  188. const options = useMemo(() => {
  189. return {
  190. clientSecret: paymentIntentSecret,
  191. appearance: getStripeElementsAppearanceOptions(resolvedTheme),
  192. } as any
  193. }, [paymentIntentSecret, resolvedTheme])
  194. const onTopUpDialogVisibilityChange = (visible: boolean) => {
  195. setTopUpModalVisible(visible)
  196. if (!visible) {
  197. setCaptchaRef(null)
  198. setPaymentIntentConfirmation(undefined)
  199. setPaymentIntentSecret('')
  200. setLatestAddress(undefined)
  201. setLatestTaxId(null)
  202. }
  203. }
  204. const paymentIntentConfirmed = (paymentIntentConfirmation: PaymentIntentResult) => {
  205. // Reset payment intent secret to ensure another attempt works as expected
  206. setPaymentIntentSecret('')
  207. setPaymentIntentConfirmation(paymentIntentConfirmation)
  208. if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') {
  209. onSuccessfulPayment()
  210. }
  211. }
  212. const onSuccessfulPayment = async () => {
  213. onTopUpDialogVisibilityChange(false)
  214. await Promise.all([
  215. queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgSubscription(slug) }),
  216. queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgBalance(slug) }),
  217. ])
  218. toast.success(
  219. 'Successfully topped up balance. It may take a minute to reflect in your account.'
  220. )
  221. }
  222. return (
  223. <Dialog open={topUpModalVisible} onOpenChange={(open) => onTopUpDialogVisibilityChange(open)}>
  224. <DialogTrigger asChild>
  225. <ButtonTooltip
  226. type="default"
  227. className="pointer-events-auto"
  228. disabled={!canTopUpCredits || !isPermissionsLoaded}
  229. tooltip={{
  230. content: {
  231. side: 'bottom',
  232. text:
  233. isPermissionsLoaded && !canTopUpCredits
  234. ? 'You need additional permissions to top up credits'
  235. : undefined,
  236. },
  237. }}
  238. >
  239. Top Up
  240. </ButtonTooltip>
  241. </DialogTrigger>
  242. <DialogContent onInteractOutside={(e) => e.preventDefault()}>
  243. <HCaptcha
  244. ref={captchaRefCallback}
  245. sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
  246. size="invisible"
  247. onOpen={() => {
  248. // [Joshen] This is to ensure that hCaptcha popup remains clickable
  249. if (document !== undefined) document.body.classList.add('pointer-events-auto!')
  250. }}
  251. onClose={() => {
  252. if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
  253. }}
  254. onVerify={(token) => {
  255. setCaptchaToken(token)
  256. if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
  257. }}
  258. onExpire={() => {
  259. setCaptchaToken(null)
  260. }}
  261. />
  262. <DialogHeader>
  263. <DialogTitle>Top Up Credits</DialogTitle>
  264. <DialogDescription className="space-y-2">
  265. <p className="prose text-sm">
  266. On successful payment, an invoice will be issued and you'll be granted credits equal
  267. to the pre-tax amount. Credits will be applied to future invoices only and are not
  268. refundable. The topped up credits do not expire.
  269. </p>
  270. <p className="prose text-sm">
  271. For larger discounted credit packages, please reach out to us via{' '}
  272. <SupportLink
  273. queryParams={{
  274. orgSlug: slug,
  275. projectRef: NO_PROJECT_MARKER,
  276. subject: 'I would like to inquire about larger credit packages',
  277. category: SupportCategories.SALES_ENQUIRY,
  278. }}
  279. >
  280. support
  281. </SupportLink>
  282. .
  283. </p>
  284. </DialogDescription>
  285. </DialogHeader>
  286. <DialogSectionSeparator />
  287. <Form {...form}>
  288. <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
  289. <DialogSection className="flex flex-col gap-2">
  290. <FormField
  291. control={form.control}
  292. name="amount"
  293. render={({ field }) => (
  294. <FormItemLayout label="Amount (USD)" className="gap-1">
  295. <Input {...field} type="number" placeholder="300" />
  296. </FormItemLayout>
  297. )}
  298. />
  299. <FormField
  300. control={form.control}
  301. name="paymentMethod"
  302. render={() => (
  303. <PaymentMethodSelection
  304. ref={paymentMethodSelectionRef}
  305. onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
  306. selectedPaymentMethod={form.getValues('paymentMethod')}
  307. readOnly={executingTopUp || paymentConfirmationLoading}
  308. useAsDefaultBillingAddress={useAsDefaultBillingAddress}
  309. onUseAsDefaultBillingAddressChange={setUseAsDefaultBillingAddress}
  310. onAddressChange={handleAddressChange}
  311. onTaxIdChange={handleTaxIdChange}
  312. />
  313. )}
  314. />
  315. {paymentIntentConfirmation && paymentIntentConfirmation.error && (
  316. <Alert variant="destructive">
  317. <AlertCircle className="h-4 w-4" />
  318. <AlertTitle>Error confirming payment</AlertTitle>
  319. <AlertDescription>{paymentIntentConfirmation.error.message}</AlertDescription>
  320. </Alert>
  321. )}
  322. {paymentIntentConfirmation?.paymentIntent &&
  323. paymentIntentConfirmation.paymentIntent.status === 'processing' && (
  324. <Alert variant="default">
  325. <Info className="h-4 w-4" />
  326. <AlertTitle>Payment processing</AlertTitle>
  327. <AlertDescription>
  328. Your payment is processing and we are waiting for a confirmation from your
  329. card issuer. If the payment goes through you'll automatically be credited.
  330. Please check back later.
  331. </AlertDescription>
  332. </Alert>
  333. )}
  334. {errorInitiatingTopUp && (
  335. <Alert variant="destructive">
  336. <AlertCircle className="h-4 w-4" />
  337. <AlertTitle>Error topping up balance</AlertTitle>
  338. <AlertDescription>{errorInitiatingTopUp.message}</AlertDescription>
  339. </Alert>
  340. )}
  341. {!!validAmount && !creditPreviewInitialized && creditPreviewIsFetching && (
  342. <div className="space-y-2 mt-4">
  343. <ShimmeringLoader />
  344. <ShimmeringLoader className="w-3/4" />
  345. <ShimmeringLoader className="w-1/2" />
  346. </div>
  347. )}
  348. {creditPreviewInitialized && !!validAmount && (
  349. <div className="mt-4">
  350. <ChargeBreakdown
  351. subtotal={creditPreview.amount}
  352. total={creditPreview.total}
  353. tax={
  354. creditPreview.tax
  355. ? {
  356. amount: creditPreview.tax.tax_amount,
  357. percentage: creditPreview.tax.tax_rate_percentage,
  358. }
  359. : undefined
  360. }
  361. taxStatus={creditPreview.tax_status}
  362. isFetching={creditPreviewIsFetching}
  363. />
  364. {creditPreview.tax_status === 'calculated' &&
  365. creditPreview.tax &&
  366. creditPreview.tax.tax_amount > 0 && (
  367. <p className="mt-2 text-xs text-foreground-light">
  368. You'll receive {formatCurrency(creditPreview.amount)} in credits.
  369. </p>
  370. )}
  371. </div>
  372. )}
  373. </DialogSection>
  374. {!paymentIntentConfirmation?.paymentIntent && (
  375. <DialogFooter>
  376. <Button
  377. htmlType="submit"
  378. type="primary"
  379. loading={
  380. form.formState.isSubmitting || executingTopUp || paymentConfirmationLoading
  381. }
  382. disabled={isPreviewStale || creditPreviewIsFetching}
  383. >
  384. Top Up
  385. </Button>
  386. </DialogFooter>
  387. )}
  388. </form>
  389. </Form>
  390. {stripePromise && paymentIntentSecret && (
  391. <Elements stripe={stripePromise} options={options}>
  392. <PaymentConfirmation
  393. paymentIntentSecret={paymentIntentSecret}
  394. onPaymentIntentConfirm={(paymentIntentConfirmation) =>
  395. paymentIntentConfirmed(paymentIntentConfirmation)
  396. }
  397. onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
  398. />
  399. </Elements>
  400. )}
  401. </DialogContent>
  402. </Dialog>
  403. )
  404. }