import HCaptcha from '@hcaptcha/react-hcaptcha' import { zodResolver } from '@hookform/resolvers/zod' import { Elements } from '@stripe/react-stripe-js' import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js' import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import { useDebounce } from '@uidotdev/usehooks' import { AlertCircle, Info } from 'lucide-react' import { useTheme } from 'next-themes' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Alert, AlertDescription, AlertTitle, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, DialogTrigger, Form, FormField, Input, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { z } from 'zod' import type { PaymentMethodElementRef } from '../../Billing/Payment/PaymentMethods/NewPaymentMethodElement' import PaymentMethodSelection from './Subscription/PaymentMethodSelection' import { ChargeBreakdown } from '@/components/interfaces/Billing/ChargeBreakdown' import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils' import { PaymentConfirmation } from '@/components/interfaces/Billing/Payment/PaymentConfirmation' import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useOrganizationCreditTopUpMutation } from '@/data/organizations/organization-credit-top-up-mutation' import { useCreditTopUpPreview } from '@/data/organizations/organization-credit-top-up-preview' import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types' import { subscriptionKeys } from '@/data/subscriptions/keys' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { STRIPE_PUBLIC_KEY } from '@/lib/constants' import { formatCurrency } from '@/lib/helpers' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) const FORM_ID = 'credit-top-up' const MIN_TOP_UP_AMOUNT = 300 const MAX_TOP_UP_AMOUNT = 2000 const FormSchema = z.object({ amount: z.coerce .number() .gte(MIN_TOP_UP_AMOUNT, `Amount must be between $${MIN_TOP_UP_AMOUNT} - $${MAX_TOP_UP_AMOUNT}.`) .lte(MAX_TOP_UP_AMOUNT, `Amount must be between $${MIN_TOP_UP_AMOUNT} - $${MAX_TOP_UP_AMOUNT}.`) .int('Amount must be a whole number.'), paymentMethod: z.string(), }) type CreditTopUpForm = z.infer export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const { resolvedTheme } = useTheme() const queryClient = useQueryClient() const paymentMethodSelectionRef = useRef<{ createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] validateBillingProfile: () => Promise }>(null) const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) const { mutateAsync: topUpCredits, isPending: executingTopUp, error: errorInitiatingTopUp, } = useOrganizationCreditTopUpMutation({}) const form = useForm({ resolver: zodResolver(FormSchema as any), defaultValues: { amount: 300, paymentMethod: '', }, }) const [topUpModalVisible, setTopUpModalVisible] = useState(false) const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const [latestAddress, setLatestAddress] = useState() const [latestTaxId, setLatestTaxId] = useState() const billingAddress = useAsDefaultBillingAddress ? latestAddress : undefined const billingTaxId = useAsDefaultBillingAddress ? latestTaxId : null const debouncedAddress = useDebounce(billingAddress, 1000) const debouncedTaxId = useDebounce(billingTaxId, 1000) const watchedAmount = form.watch('amount') const debouncedAmount = useDebounce(watchedAmount, 1000) const parsedAmount = Number(debouncedAmount) const validAmount = !Number.isNaN(parsedAmount) && Number.isInteger(parsedAmount) && parsedAmount >= MIN_TOP_UP_AMOUNT && parsedAmount <= MAX_TOP_UP_AMOUNT ? parsedAmount : undefined const isPreviewStale = watchedAmount !== debouncedAmount || billingAddress !== debouncedAddress || billingTaxId !== debouncedTaxId const handleAddressChange = useCallback((address: CustomerAddress) => { setLatestAddress(address) }, []) const handleTaxIdChange = useCallback((taxId: CustomerTaxId | null) => { setLatestTaxId(taxId) }, []) const { data: creditPreview, isFetching: creditPreviewIsFetching, isSuccess: creditPreviewInitialized, } = useCreditTopUpPreview( { slug, amount: validAmount, address: debouncedAddress, taxId: debouncedTaxId ?? undefined, }, { enabled: topUpModalVisible && !!validAmount } ) const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) const captchaRefCallback = useCallback((node: any) => { setCaptchaRef(node) }, []) const resetCaptcha = () => { setCaptchaToken(null) captchaRef?.resetCaptcha() } const initHcaptcha = async () => { if (topUpModalVisible && captchaRef) { let token = captchaToken try { if (!token) { const captchaResponse = await captchaRef.execute({ async: true }) token = captchaResponse?.response ?? null setCaptchaToken(token) return token } } catch (error) { return token } return token } } useEffect(() => { initHcaptcha() }, [topUpModalVisible, captchaRef]) const [paymentIntentSecret, setPaymentIntentSecret] = useState('') const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState() const onSubmit: SubmitHandler = async ({ amount }) => { setPaymentIntentConfirmation(undefined) const token = await initHcaptcha() const isValid = await paymentMethodSelectionRef.current?.validateBillingProfile() if (!isValid) return const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod() if (!paymentMethodResult) { return } await topUpCredits( { slug, amount, payment_method_id: paymentMethodResult.paymentMethod.id, hcaptchaToken: token, address: paymentMethodResult.address, tax_id: paymentMethodResult.taxId ?? undefined, billing_name: paymentMethodResult.customerName, }, { onSuccess: (data) => { if (data.status === 'succeeded') { onSuccessfulPayment() } else { setPaymentIntentSecret(data.payment_intent_secret || '') } resetCaptcha() }, } ) } const options = useMemo(() => { return { clientSecret: paymentIntentSecret, appearance: getStripeElementsAppearanceOptions(resolvedTheme), } as any }, [paymentIntentSecret, resolvedTheme]) const onTopUpDialogVisibilityChange = (visible: boolean) => { setTopUpModalVisible(visible) if (!visible) { setCaptchaRef(null) setPaymentIntentConfirmation(undefined) setPaymentIntentSecret('') setLatestAddress(undefined) setLatestTaxId(null) } } const paymentIntentConfirmed = (paymentIntentConfirmation: PaymentIntentResult) => { // Reset payment intent secret to ensure another attempt works as expected setPaymentIntentSecret('') setPaymentIntentConfirmation(paymentIntentConfirmation) if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { onSuccessfulPayment() } } const onSuccessfulPayment = async () => { onTopUpDialogVisibilityChange(false) await Promise.all([ queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgSubscription(slug) }), queryClient.invalidateQueries({ queryKey: subscriptionKeys.orgBalance(slug) }), ]) toast.success( 'Successfully topped up balance. It may take a minute to reflect in your account.' ) } return ( onTopUpDialogVisibilityChange(open)}> Top Up e.preventDefault()}> { // [Joshen] This is to ensure that hCaptcha popup remains clickable if (document !== undefined) document.body.classList.add('pointer-events-auto!') }} onClose={() => { if (document !== undefined) document.body.classList.remove('pointer-events-auto!') }} onVerify={(token) => { setCaptchaToken(token) if (document !== undefined) document.body.classList.remove('pointer-events-auto!') }} onExpire={() => { setCaptchaToken(null) }} /> Top Up Credits

On successful payment, an invoice will be issued and you'll be granted credits equal to the pre-tax amount. Credits will be applied to future invoices only and are not refundable. The topped up credits do not expire.

For larger discounted credit packages, please reach out to us via{' '} support .

( )} /> ( form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} readOnly={executingTopUp || paymentConfirmationLoading} useAsDefaultBillingAddress={useAsDefaultBillingAddress} onUseAsDefaultBillingAddressChange={setUseAsDefaultBillingAddress} onAddressChange={handleAddressChange} onTaxIdChange={handleTaxIdChange} /> )} /> {paymentIntentConfirmation && paymentIntentConfirmation.error && ( Error confirming payment {paymentIntentConfirmation.error.message} )} {paymentIntentConfirmation?.paymentIntent && paymentIntentConfirmation.paymentIntent.status === 'processing' && ( Payment processing Your payment is processing and we are waiting for a confirmation from your card issuer. If the payment goes through you'll automatically be credited. Please check back later. )} {errorInitiatingTopUp && ( Error topping up balance {errorInitiatingTopUp.message} )} {!!validAmount && !creditPreviewInitialized && creditPreviewIsFetching && (
)} {creditPreviewInitialized && !!validAmount && (
{creditPreview.tax_status === 'calculated' && creditPreview.tax && creditPreview.tax.tax_amount > 0 && (

You'll receive {formatCurrency(creditPreview.amount)} in credits.

)}
)}
{!paymentIntentConfirmation?.paymentIntent && ( )}
{stripePromise && paymentIntentSecret && ( paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} /> )}
) }