| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- 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<typeof FormSchema>
- export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
- const { resolvedTheme } = useTheme()
- const queryClient = useQueryClient()
- const paymentMethodSelectionRef = useRef<{
- createPaymentMethod: PaymentMethodElementRef['createPaymentMethod']
- validateBillingProfile: () => Promise<boolean>
- }>(null)
- const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
- PermissionAction.BILLING_WRITE,
- 'stripe.subscriptions'
- )
- const {
- mutateAsync: topUpCredits,
- isPending: executingTopUp,
- error: errorInitiatingTopUp,
- } = useOrganizationCreditTopUpMutation({})
- const form = useForm<CreditTopUpForm>({
- 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<CustomerAddress>()
- const [latestTaxId, setLatestTaxId] = useState<CustomerTaxId | null>()
- 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<string | null>(null)
- const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(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<PaymentIntentResult>()
- const onSubmit: SubmitHandler<CreditTopUpForm> = 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 (
- <Dialog open={topUpModalVisible} onOpenChange={(open) => onTopUpDialogVisibilityChange(open)}>
- <DialogTrigger asChild>
- <ButtonTooltip
- type="default"
- className="pointer-events-auto"
- disabled={!canTopUpCredits || !isPermissionsLoaded}
- tooltip={{
- content: {
- side: 'bottom',
- text:
- isPermissionsLoaded && !canTopUpCredits
- ? 'You need additional permissions to top up credits'
- : undefined,
- },
- }}
- >
- Top Up
- </ButtonTooltip>
- </DialogTrigger>
- <DialogContent onInteractOutside={(e) => e.preventDefault()}>
- <HCaptcha
- ref={captchaRefCallback}
- sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
- size="invisible"
- onOpen={() => {
- // [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)
- }}
- />
- <DialogHeader>
- <DialogTitle>Top Up Credits</DialogTitle>
- <DialogDescription className="space-y-2">
- <p className="prose text-sm">
- 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.
- </p>
- <p className="prose text-sm">
- For larger discounted credit packages, please reach out to us via{' '}
- <SupportLink
- queryParams={{
- orgSlug: slug,
- projectRef: NO_PROJECT_MARKER,
- subject: 'I would like to inquire about larger credit packages',
- category: SupportCategories.SALES_ENQUIRY,
- }}
- >
- support
- </SupportLink>
- .
- </p>
- </DialogDescription>
- </DialogHeader>
- <DialogSectionSeparator />
- <Form {...form}>
- <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
- <DialogSection className="flex flex-col gap-2">
- <FormField
- control={form.control}
- name="amount"
- render={({ field }) => (
- <FormItemLayout label="Amount (USD)" className="gap-1">
- <Input {...field} type="number" placeholder="300" />
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="paymentMethod"
- render={() => (
- <PaymentMethodSelection
- ref={paymentMethodSelectionRef}
- onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
- selectedPaymentMethod={form.getValues('paymentMethod')}
- readOnly={executingTopUp || paymentConfirmationLoading}
- useAsDefaultBillingAddress={useAsDefaultBillingAddress}
- onUseAsDefaultBillingAddressChange={setUseAsDefaultBillingAddress}
- onAddressChange={handleAddressChange}
- onTaxIdChange={handleTaxIdChange}
- />
- )}
- />
- {paymentIntentConfirmation && paymentIntentConfirmation.error && (
- <Alert variant="destructive">
- <AlertCircle className="h-4 w-4" />
- <AlertTitle>Error confirming payment</AlertTitle>
- <AlertDescription>{paymentIntentConfirmation.error.message}</AlertDescription>
- </Alert>
- )}
- {paymentIntentConfirmation?.paymentIntent &&
- paymentIntentConfirmation.paymentIntent.status === 'processing' && (
- <Alert variant="default">
- <Info className="h-4 w-4" />
- <AlertTitle>Payment processing</AlertTitle>
- <AlertDescription>
- 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.
- </AlertDescription>
- </Alert>
- )}
- {errorInitiatingTopUp && (
- <Alert variant="destructive">
- <AlertCircle className="h-4 w-4" />
- <AlertTitle>Error topping up balance</AlertTitle>
- <AlertDescription>{errorInitiatingTopUp.message}</AlertDescription>
- </Alert>
- )}
- {!!validAmount && !creditPreviewInitialized && creditPreviewIsFetching && (
- <div className="space-y-2 mt-4">
- <ShimmeringLoader />
- <ShimmeringLoader className="w-3/4" />
- <ShimmeringLoader className="w-1/2" />
- </div>
- )}
- {creditPreviewInitialized && !!validAmount && (
- <div className="mt-4">
- <ChargeBreakdown
- subtotal={creditPreview.amount}
- total={creditPreview.total}
- tax={
- creditPreview.tax
- ? {
- amount: creditPreview.tax.tax_amount,
- percentage: creditPreview.tax.tax_rate_percentage,
- }
- : undefined
- }
- taxStatus={creditPreview.tax_status}
- isFetching={creditPreviewIsFetching}
- />
- {creditPreview.tax_status === 'calculated' &&
- creditPreview.tax &&
- creditPreview.tax.tax_amount > 0 && (
- <p className="mt-2 text-xs text-foreground-light">
- You'll receive {formatCurrency(creditPreview.amount)} in credits.
- </p>
- )}
- </div>
- )}
- </DialogSection>
- {!paymentIntentConfirmation?.paymentIntent && (
- <DialogFooter>
- <Button
- htmlType="submit"
- type="primary"
- loading={
- form.formState.isSubmitting || executingTopUp || paymentConfirmationLoading
- }
- disabled={isPreviewStale || creditPreviewIsFetching}
- >
- Top Up
- </Button>
- </DialogFooter>
- )}
- </form>
- </Form>
- {stripePromise && paymentIntentSecret && (
- <Elements stripe={stripePromise} options={options}>
- <PaymentConfirmation
- paymentIntentSecret={paymentIntentSecret}
- onPaymentIntentConfirm={(paymentIntentConfirmation) =>
- paymentIntentConfirmed(paymentIntentConfirmation)
- }
- onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
- />
- </Elements>
- )}
- </DialogContent>
- </Dialog>
- )
- }
|