CreditTopUp.tsx450 lines · main
| 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 | |
| 35 | import type { PaymentMethodElementRef } from '../../Billing/Payment/PaymentMethods/NewPaymentMethodElement' |
| 36 | import PaymentMethodSelection from './Subscription/PaymentMethodSelection' |
| 37 | import { ChargeBreakdown } from '@/components/interfaces/Billing/ChargeBreakdown' |
| 38 | import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils' |
| 39 | import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation' |
| 40 | import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils' |
| 41 | import { SupportLink } from '@/components/interfaces/Support/SupportLink' |
| 42 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 43 | import { useOrganizationCreditTopUpMutation } from '@/data/organizations/organization-credit-top-up-mutation' |
| 44 | import { useCreditTopUpPreview } from '@/data/organizations/organization-credit-top-up-preview' |
| 45 | import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' |
| 46 | import { subscriptionKeys } from '@/data/subscriptions/keys' |
| 47 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 48 | import { STRIPE_PUBLIC_KEY } from '@/lib/constants' |
| 49 | import { formatCurrency } from '@/lib/helpers' |
| 50 | |
| 51 | const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) |
| 52 | |
| 53 | const FORM_ID = 'credit-top-up' |
| 54 | const MIN_TOP_UP_AMOUNT = 300 |
| 55 | const MAX_TOP_UP_AMOUNT = 2000 |
| 56 | |
| 57 | const 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 | |
| 66 | type CreditTopUpForm = z.infer<typeof FormSchema> |
| 67 | |
| 68 | export 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 | } |