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