diff --git a/src/core/modules/billing/models.ts b/src/core/modules/billing/models.ts index e79e3233e..51f4f6547 100644 --- a/src/core/modules/billing/models.ts +++ b/src/core/modules/billing/models.ts @@ -55,10 +55,24 @@ export interface AddOnOrderConfirmResponse { client_secret: string } +export interface VerificationPaymentResponse { + client_secret: string + amount_due_cents: number +} + export interface PaymentMethodsCustomerSession { client_secret: string } +export interface PaymentMethodsSession { + client_secret: string + setup_intent_client_secret?: string +} + +export interface PaymentMethodsSetupSession extends PaymentMethodsSession { + setup_intent_client_secret: string +} + export interface TierLimits { sandbox_concurrency: number max_cpu: number diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 852b21651..4cc79c1e3 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -1,5 +1,6 @@ import 'server-only' +import { z } from 'zod' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import type { AddOnOrderConfirmResponse, @@ -8,8 +9,10 @@ import type { CustomerPortalResponse, Invoice, PaymentMethodsCustomerSession, + PaymentMethodsSetupSession, TeamItems, UsageResponse, + VerificationPaymentResponse, } from '@/core/modules/billing/models' import { repoErrorFromHttp } from '@/core/shared/errors' import type { TeamRequestScope } from '@/core/shared/repository-scope' @@ -35,12 +38,24 @@ export interface BillingRepository { createOrder(itemId: string): Promise> confirmOrder(orderId: string): Promise> getCustomerSession(): Promise> + createPaymentMethodsSession(): Promise> + createVerificationPayment(): Promise> } async function parseText(response: Response): Promise { return (await response.text()) || 'Request failed' } +const PaymentMethodsSessionResponseSchema = z.object({ + client_secret: z.string().min(1), + setup_intent_client_secret: z.string().min(1), +}) + +const VerificationPaymentResponseSchema = z.object({ + client_secret: z.string().min(1), + amount_due_cents: z.number().int().positive(), +}) + export function createBillingRepository( scope: BillingScope, deps: BillingRepositoryDeps = { @@ -249,7 +264,7 @@ export function createBillingRepository( }, async getCustomerSession() { const res = await fetch( - `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods/customer-session`, + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session`, { method: 'POST', headers: { @@ -265,5 +280,61 @@ export function createBillingRepository( return ok((await res.json()) as PaymentMethodsCustomerSession) }, + async createPaymentMethodsSession() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/payment-methods-session?include_setup_intent=true`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + const parseResult = PaymentMethodsSessionResponseSchema.safeParse( + await res.json() + ) + + if (!parseResult.success) { + return err( + repoErrorFromHttp(500, 'Invalid payment methods session response') + ) + } + + return ok(parseResult.data) + }, + async createVerificationPayment() { + const res = await fetch( + `${deps.billingApiUrl}/teams/${scope.teamId}/verification-payment`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + }, + } + ) + + if (!res.ok) { + return err(repoErrorFromHttp(res.status, await parseText(res))) + } + + const parseResult = VerificationPaymentResponseSchema.safeParse( + await res.json() + ) + + if (!parseResult.success) { + return err( + repoErrorFromHttp(500, 'Invalid verification payment response') + ) + } + + return ok(parseResult.data) + }, } } diff --git a/src/core/modules/teams/constants.ts b/src/core/modules/teams/constants.ts new file mode 100644 index 000000000..f9ebdcc48 --- /dev/null +++ b/src/core/modules/teams/constants.ts @@ -0,0 +1,6 @@ +const TEAM_BLOCKED_REASONS = { + missingPayment: 'missing payment method', + verification: 'verification required', +} as const + +export { TEAM_BLOCKED_REASONS } diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts index 9dd922c85..d9b651ca0 100644 --- a/src/core/modules/teams/models.ts +++ b/src/core/modules/teams/models.ts @@ -1,7 +1,10 @@ import type { components as DashboardComponents } from '@/contracts/dashboard-api' +import type { TEAM_BLOCKED_REASONS } from './constants' export type TeamModel = DashboardComponents['schemas']['UserTeam'] export type TeamLimits = DashboardComponents['schemas']['UserTeamLimits'] +export type TeamBlockedReason = + (typeof TEAM_BLOCKED_REASONS)[keyof typeof TEAM_BLOCKED_REASONS] export type TeamMemberInfo = { id: string diff --git a/src/core/server/api/routers/billing.ts b/src/core/server/api/routers/billing.ts index 2419c7ef1..c7bdf0154 100644 --- a/src/core/server/api/routers/billing.ts +++ b/src/core/server/api/routers/billing.ts @@ -2,7 +2,6 @@ import { TRPCError } from '@trpc/server' import { headers } from 'next/headers' import { z } from 'zod' import { createBillingRepository } from '@/core/modules/billing/repository.server' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' @@ -25,12 +24,6 @@ const billingRepositoryProcedure = protectedTeamProcedure.use( ) ) -const billingAndTeamsRepositoryProcedure = billingRepositoryProcedure.use( - withTeamAuthedRequestRepository(createTeamsRepository, (teamsRepository) => ({ - teamsRepository, - })) -) - export const billingRouter = createTRPCRouter({ createCheckout: billingRepositoryProcedure .input(z.object({ tierId: z.string() })) @@ -136,4 +129,19 @@ export const billingRouter = createTRPCRouter({ if (!result.ok) throwTRPCErrorFromRepoError(result.error) return result.data }), + + createPaymentMethodsSession: billingRepositoryProcedure.mutation( + async ({ ctx }) => { + const result = await ctx.billingRepository.createPaymentMethodsSession() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + } + ), + createVerificationPayment: billingRepositoryProcedure.mutation( + async ({ ctx }) => { + const result = await ctx.billingRepository.createVerificationPayment() + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + return result.data + } + ), }) diff --git a/src/features/dashboard/billing/hooks.ts b/src/features/dashboard/billing/hooks.ts index de0d1f4c4..8fb42f41d 100644 --- a/src/features/dashboard/billing/hooks.ts +++ b/src/features/dashboard/billing/hooks.ts @@ -140,6 +140,21 @@ export function usePaymentElementAppearance() { '.AccordionButton--selected:hover': { backgroundColor: isDark ? '#1f1f1f' : '#f2f2f2', }, + '.CheckboxInput': { + backgroundColor: isDark ? '#1f1f1f' : '#ffffff', + border: isDark ? '1px solid #424242' : '1px solid #707070', + boxShadow: 'none', + }, + '.CheckboxInput:hover': { + border: isDark ? '1px solid #848484' : '1px solid #333333', + }, + '.CheckboxInput--checked': { + backgroundColor: isDark ? '#ff8800' : '#e56f00', + border: isDark ? '1px solid #ff8800' : '1px solid #e56f00', + }, + '.CheckboxLabel': { + color: isDark ? '#e6e6e6' : '#333333', + }, '.Spinner': { color: isDark ? '#ff8800' : '#e56f00', // accent-main-highlight borderColor: isDark diff --git a/src/features/dashboard/layouts/team-blocked-indicator.tsx b/src/features/dashboard/layouts/team-blocked-indicator.tsx index d691f8866..d0d50b62d 100644 --- a/src/features/dashboard/layouts/team-blocked-indicator.tsx +++ b/src/features/dashboard/layouts/team-blocked-indicator.tsx @@ -1,68 +1,156 @@ 'use client' import Link from 'next/link' -import { useMemo } from 'react' -import { PROTECTED_URLS } from '@/configs/urls' +import { useEffect, useMemo, useState } from 'react' +import { useSessionStorage } from 'usehooks-ts' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' +import type { TeamBlockedReason } from '@/core/modules/teams/models' import { useDashboard } from '@/features/dashboard/context' +import { Button } from '@/ui/primitives/button' import { BlockIcon } from '@/ui/primitives/icons' +import { + MissingPaymentMethodDialog, + VerificationRequiredDialog, +} from '../team-blocked' +import { getBlockedDialogStorageKey } from '../team-blocked/team-blocked-dialog-storage' +import { + type BlockedMessage, + getBlockedMessage, +} from '../team-blocked/team-blocked-message' -function useBlockedMessage(slug: string, blockedReason: string | null) { - return useMemo(() => { - const reason = blockedReason?.toLowerCase() ?? '' +interface TeamBlockedIndicatorContentProps { + message: BlockedMessage + onDialogAction?: () => void +} - if (reason.includes('billing limit')) { - return { - text: 'Billing limit reached.', - cta: 'Update limit.', - href: PROTECTED_URLS.LIMITS(slug), - } - } +const TeamBlockedIndicatorContent = ({ + message, + onDialogAction, +}: TeamBlockedIndicatorContentProps) => { + return ( +
+ + + {message.text} + {message.cta && ( + <> + {' '} + {message.href ? ( + + {message.cta} + + ) : onDialogAction ? ( + + ) : null} + + )} + +
+ ) +} - if (reason.includes('missing payment method')) { - return { - text: 'Missing payment method.', - cta: 'Add payment method.', - href: PROTECTED_URLS.BILLING(slug), - } - } +const useBlockedMessage = ( + slug: string, + blockedReason: string | null +): BlockedMessage => { + return useMemo( + () => getBlockedMessage(slug, blockedReason), + [slug, blockedReason] + ) +} - if (reason.includes('verification required')) { - return { - text: 'Verification required.', - cta: 'Add payment method.', - href: PROTECTED_URLS.BILLING(slug), - } - } +interface TeamBlockedDialogControllerProps { + blockedReasonDialog: TeamBlockedReason + message: BlockedMessage +} - return { - text: blockedReason ?? 'Team suspended.', - cta: null, - href: null, +const TeamBlockedDialogController = ({ + blockedReasonDialog, + message, +}: TeamBlockedDialogControllerProps) => { + const { team } = useDashboard() + const [openDialog, setOpenDialog] = useState(null) + const dismissedStorageKey = getBlockedDialogStorageKey( + team.slug, + blockedReasonDialog + ) + const [hasDismissedDialog, setHasDismissedDialog] = useSessionStorage( + dismissedStorageKey, + false + ) + + useEffect(() => { + if (hasDismissedDialog) { + setOpenDialog(null) + return } - }, [slug, blockedReason]) + + setOpenDialog(blockedReasonDialog) + }, [blockedReasonDialog, hasDismissedDialog]) + + const handleDialogAction = () => { + setOpenDialog(blockedReasonDialog) + } + + const handleDialogOpenChange = (open: boolean, dialog: TeamBlockedReason) => { + setOpenDialog(open ? dialog : null) + } + + const handleDialogDismiss = () => { + setHasDismissedDialog(true) + setOpenDialog(null) + } + + return ( + <> + + { + handleDialogOpenChange(open, TEAM_BLOCKED_REASONS.missingPayment) + }} + onDismiss={handleDialogDismiss} + /> + { + handleDialogOpenChange(open, TEAM_BLOCKED_REASONS.verification) + }} + onDismiss={handleDialogDismiss} + /> + + ) } export default function TeamBlockedIndicator() { const { team } = useDashboard() - const message = useBlockedMessage(team.slug, team.blockedReason) + const reason = team.blockedReason?.toLowerCase() ?? '' + const blockedReasonDialog: TeamBlockedReason | null = + Object.values(TEAM_BLOCKED_REASONS).find((blockedReason) => + reason.includes(blockedReason) + ) ?? null if (!team.isBlocked) return null + if (!blockedReasonDialog) + return + return ( -
- - - {message.text} - {message.cta && message.href && ( - <> - {' '} - - {message.cta} - - - )} - -
+ ) } diff --git a/src/features/dashboard/team-blocked/index.ts b/src/features/dashboard/team-blocked/index.ts new file mode 100644 index 000000000..1b1be421d --- /dev/null +++ b/src/features/dashboard/team-blocked/index.ts @@ -0,0 +1,2 @@ +export { MissingPaymentMethodDialog } from './missing-payment-method-dialog' +export { VerificationRequiredDialog } from './verification-required-dialog' diff --git a/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx new file mode 100644 index 000000000..19670bf94 --- /dev/null +++ b/src/features/dashboard/team-blocked/missing-payment-method-dialog.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { parseAsString, useQueryStates } from 'nuqs' +import { usePostHog } from 'posthog-js/react' +import { useCallback, useState } from 'react' +import { useSessionStorage } from 'usehooks-ts' +import type { PaymentMethodsSetupSession } from '@/core/modules/billing/models' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Alert, AlertDescription } from '@/ui/primitives/alert' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { CardIcon } from '@/ui/primitives/icons' +import { useDashboard } from '../context' +import { getBlockedDialogStorageKey } from './team-blocked-dialog-storage' +import { + LoadingState, + TeamBlockedRecoveryPaymentElement, + useStripeReturnHandler, +} from './team-blocked-recovery' + +const PAYMENT_METHOD_LOADING_MESSAGE = 'Loading payment method...' +const PAYMENT_METHOD_ADDED_EVENT = 'payment_method_added' + +const stripeSetupIntentParams = { + setup_intent: parseAsString, + setup_intent_client_secret: parseAsString, + redirect_status: parseAsString, +} + +interface MissingPaymentMethodDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onDismiss: () => void +} + +export const MissingPaymentMethodDialog = ({ + open, + onOpenChange, + onDismiss, +}: MissingPaymentMethodDialogProps) => { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) return onDismiss() + + onOpenChange(nextOpen) + } + + return ( + + + onOpenChange(false)} + /> + + + ) +} + +interface MissingPaymentMethodDialogContentProps { + open: boolean + onClose: () => void +} + +const MissingPaymentMethodDialogContent = ({ + open, + onClose, +}: MissingPaymentMethodDialogContentProps) => { + const { team } = useDashboard() + const { toast } = useToast() + const posthog = usePostHog() + const trpc = useTRPC() + const router = useRouter() + const [, , removeDismissedDialog] = useSessionStorage( + getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.missingPayment), + false + ) + const [paymentMethodsSession, setPaymentMethodsSession] = + useState(null) + const [isLoadingPaymentMethodsSession, setIsLoadingPaymentMethodsSession] = + useState(false) + const [setupIntentParams, setSetupIntentParams] = useQueryStates( + stripeSetupIntentParams, + { + history: 'replace', + shallow: true, + } + ) + + const paymentMethodsSessionMutation = useMutation( + trpc.billing.createPaymentMethodsSession.mutationOptions({ + onError: (error) => { + toast( + defaultErrorToast( + error.message || 'Failed to load payment method form.' + ) + ) + onClose() + }, + }) + ) + + const createPaymentMethodsSession = useCallback(async () => { + setIsLoadingPaymentMethodsSession(true) + + try { + const session = await paymentMethodsSessionMutation.mutateAsync({ + teamSlug: team.slug, + }) + setPaymentMethodsSession(session) + } catch { + // The mutation onError handler owns the user-facing toast and close. + } finally { + setIsLoadingPaymentMethodsSession(false) + } + }, [paymentMethodsSessionMutation.mutateAsync, team.slug]) + + useStripeReturnHandler({ + open, + clientSecret: setupIntentParams.setup_intent_client_secret, + clearReturnParams: () => { + setSetupIntentParams({ + setup_intent: null, + setup_intent_client_secret: null, + redirect_status: null, + }) + }, + createPaymentSession: createPaymentMethodsSession, + retrieveStatus: async (stripe, clientSecret) => { + const { setupIntent, error } = + await stripe.retrieveSetupIntent(clientSecret) + + return { + status: setupIntent?.status, + errorMessage: error?.message, + } + }, + onSucceeded: () => { + toast({ + variant: 'success', + title: 'Payment method added', + description: 'Your payment method was added successfully.', + }) + posthog.capture(PAYMENT_METHOD_ADDED_EVENT) + removeDismissedDialog() + onClose() + router.refresh() + }, + onProcessing: () => { + toast({ + title: 'Payment method processing', + description: + 'Your bank is still processing this payment method. Please check again in a moment.', + }) + router.refresh() + onClose() + }, + requiresPaymentMethodMessage: + 'Payment method setup was not completed. Please try again.', + retrieveErrorMessage: 'Failed to check payment method status.', + fallbackErrorMessage: 'Failed to check payment method status.', + }) + + return ( + <> + + Add payment method + + This team is blocked because there is no payment method on file. Add a + card to continue using E2B. + + + + {isLoadingPaymentMethodsSession ? ( + + ) : paymentMethodsSession ? ( + + stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: returnUrl, + }, + }) + } + alert={ + + + + Your payment method will be saved to your team billing account. + + + } + loadingMessage={PAYMENT_METHOD_LOADING_MESSAGE} + submitLabel="Save payment method" + processingLabel="Saving..." + submittedToast={{ + title: 'Payment method added', + description: + 'We are checking whether your team has been unblocked.', + }} + successToast={{ + variant: 'success', + title: 'Team unblocked', + description: + 'Your payment method was added and your team has been unblocked.', + }} + onSuccess={() => { + posthog.capture(PAYMENT_METHOD_ADDED_EVENT) + removeDismissedDialog() + }} + errorMessages={{ + ready: 'Payment form is still loading.', + confirm: 'Failed to save payment method. Please try again.', + stillBlocked: + 'Payment method added, but your team is still blocked. Please wait a moment and try again.', + statusCheck: + 'Payment method added, but we could not check your team status. Please refresh or try again in a moment.', + load: 'Failed to load payment details. Please refresh and try again.', + }} + /> + ) : null} + + ) +} diff --git a/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts b/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts new file mode 100644 index 000000000..e9f033c3b --- /dev/null +++ b/src/features/dashboard/team-blocked/team-blocked-dialog-storage.ts @@ -0,0 +1,11 @@ +import type { TeamBlockedReason } from '@/core/modules/teams/models' + +// Builds the dismissed-dialog session key; ("acme", "verification required") -> "team-blocked-dialog-dismissed:acme:verification required". +const getBlockedDialogStorageKey = ( + teamSlug: string, + blockedReason: TeamBlockedReason +) => { + return `team-blocked-dialog-dismissed:${teamSlug}:${blockedReason}` +} + +export { getBlockedDialogStorageKey } diff --git a/src/features/dashboard/team-blocked/team-blocked-message.ts b/src/features/dashboard/team-blocked/team-blocked-message.ts new file mode 100644 index 000000000..9f015ed95 --- /dev/null +++ b/src/features/dashboard/team-blocked/team-blocked-message.ts @@ -0,0 +1,45 @@ +import { PROTECTED_URLS } from '@/configs/urls' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' + +export interface BlockedMessage { + text: string + cta: string | null + href: string | null +} + +export function getBlockedMessage( + slug: string, + blockedReason: string | null +): BlockedMessage { + const reason = blockedReason?.toLowerCase() ?? '' + + if (reason.includes('billing limit')) { + return { + text: 'Billing limit reached.', + cta: 'Update limit.', + href: PROTECTED_URLS.LIMITS(slug), + } + } + + if (reason.includes(TEAM_BLOCKED_REASONS.missingPayment)) { + return { + text: 'Missing payment method.', + cta: 'Add payment method.', + href: null, + } + } + + if (reason.includes(TEAM_BLOCKED_REASONS.verification)) { + return { + text: 'Verification required.', + cta: 'Complete verification.', + href: null, + } + } + + return { + text: blockedReason ?? 'Team suspended.', + cta: null, + href: null, + } +} diff --git a/src/features/dashboard/team-blocked/team-blocked-recovery.tsx b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx new file mode 100644 index 000000000..19b7b29c2 --- /dev/null +++ b/src/features/dashboard/team-blocked/team-blocked-recovery.tsx @@ -0,0 +1,451 @@ +'use client' + +import { + Elements, + PaymentElement, + useElements, + useStripe, +} from '@stripe/react-stripe-js' +import type { Stripe, StripeElements, StripeError } from '@stripe/stripe-js' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import type { FormEvent, ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import type { TeamModel } from '@/core/modules/teams/models' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils/ui' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { ArrowRightIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' +import { stripePromise, usePaymentElementAppearance } from '../billing/hooks' +import { useDashboard } from '../context' + +const TEAM_UNBLOCK_POLL_ATTEMPTS = 30 +const TEAM_UNBLOCK_POLL_INTERVAL_MS = 1000 + +type ToastInput = Parameters['toast']>[0] + +interface LoadingStateProps { + message: string + className?: string +} + +export const LoadingState = ({ message, className }: LoadingStateProps) => { + return ( +
+ + {message} +
+ ) +} + +// Waits before retrying team status polling; 1000 -> resolves after 1 second. +const wait = (ms: number) => + new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) + +export const useTeamUnblockPolling = () => { + const trpc = useTRPC() + const queryClient = useQueryClient() + const { team } = useDashboard() + + const teamListQueryOptions = trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + + const teamListQueryKey = teamListQueryOptions.queryKey + + const pollUntilTeamUnblocked = useCallback(async () => { + for (let attempt = 0; attempt < TEAM_UNBLOCK_POLL_ATTEMPTS; attempt += 1) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + + const teams = await queryClient.fetchQuery({ + ...teamListQueryOptions, + staleTime: 0, + }) + + const activeTeam = teams.find( + (candidate: TeamModel) => + candidate.id === team.id || candidate.slug === team.slug + ) + + if (activeTeam && !activeTeam.isBlocked) { + await queryClient.invalidateQueries({ queryKey: teamListQueryKey }) + return true + } + + if (attempt < TEAM_UNBLOCK_POLL_ATTEMPTS - 1) + await wait(TEAM_UNBLOCK_POLL_INTERVAL_MS) + } + + return false + }, [queryClient, team.id, team.slug, teamListQueryKey, teamListQueryOptions]) + + return pollUntilTeamUnblocked +} + +interface StripeReturnStatusResult { + status?: string + errorMessage?: string | null +} + +interface StripeReturnHandlerOptions { + open: boolean + clientSecret: string | null + clearReturnParams: () => void + createPaymentSession: () => Promise + retrieveStatus: ( + stripe: Stripe, + clientSecret: string + ) => Promise + onSucceeded: () => Promise | void + onProcessing: () => void + requiresPaymentMethodMessage: string + retrieveErrorMessage: string + fallbackErrorMessage: string +} + +type StripeReturnHandlerState = 'idle' | 'checking' | 'handled' + +export const useStripeReturnHandler = ({ + open, + clientSecret, + clearReturnParams, + createPaymentSession, + retrieveStatus, + onSucceeded, + onProcessing, + requiresPaymentMethodMessage, + retrieveErrorMessage, + fallbackErrorMessage, +}: StripeReturnHandlerOptions) => { + const { toast } = useToast() + const stripeReturnHandlerState = useRef('idle') + + useEffect(() => { + if (open) return + + stripeReturnHandlerState.current = 'idle' + }, [open]) + + useEffect(() => { + if (!open) return + if (stripeReturnHandlerState.current !== 'idle') return + + stripeReturnHandlerState.current = 'checking' + + if (!clientSecret) { + createPaymentSession() + .then(() => { + stripeReturnHandlerState.current = 'handled' + }) + .catch(() => { + stripeReturnHandlerState.current = 'idle' + }) + return + } + + clearReturnParams() + + const checkStripeReturn = async () => { + const stripe = await stripePromise + + if (!stripe) { + toast(defaultErrorToast('Failed to load Stripe.')) + await createPaymentSession() + return + } + + const result = await retrieveStatus(stripe, clientSecret) + + if (result.errorMessage) { + toast(defaultErrorToast(result.errorMessage ?? retrieveErrorMessage)) + await createPaymentSession() + return + } + + if (result.status === 'succeeded') { + await onSucceeded() + return + } + + if (result.status === 'processing') { + onProcessing() + return + } + + if (result.status === 'requires_payment_method') + toast(defaultErrorToast(requiresPaymentMethodMessage)) + + await createPaymentSession() + } + + checkStripeReturn() + .then(() => { + stripeReturnHandlerState.current = 'handled' + }) + .catch(() => { + toast(defaultErrorToast(fallbackErrorMessage)) + createPaymentSession() + .then(() => { + stripeReturnHandlerState.current = 'handled' + }) + .catch(() => { + stripeReturnHandlerState.current = 'idle' + }) + }) + }, [ + clearReturnParams, + clientSecret, + createPaymentSession, + fallbackErrorMessage, + onProcessing, + onSucceeded, + open, + requiresPaymentMethodMessage, + retrieveErrorMessage, + retrieveStatus, + toast, + ]) +} + +interface StripePaymentElementWrapperProps { + clientSecret: string + customerSessionClientSecret?: string + children: ReactNode +} + +export const StripePaymentElementWrapper = ({ + clientSecret, + customerSessionClientSecret, + children, +}: StripePaymentElementWrapperProps) => { + const appearance = usePaymentElementAppearance() + + return ( + + {children} + + ) +} + +interface StripePaymentElementFormProps { + onSubmit: (params: { + stripe: Stripe + elements: StripeElements + }) => Promise + submitLabel: ReactNode + processingLabel: string + loadingMessage: string + readyErrorMessage?: string + paymentElementDefaultCollapsed?: boolean + alert?: ReactNode + isProcessing?: boolean + className?: string + buttonClassName?: string + processingFallbackMessage?: string + onLoadError?: (error: StripeError) => void +} + +export const StripePaymentElementForm = ({ + onSubmit, + submitLabel, + processingLabel, + loadingMessage, + readyErrorMessage = 'Payment form is still loading.', + paymentElementDefaultCollapsed = false, + alert, + isProcessing = false, + className, + buttonClassName, + processingFallbackMessage, + onLoadError, +}: StripePaymentElementFormProps) => { + const stripe = useStripe() + const elements = useElements() + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = useState(false) + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false) + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + + if (!stripe || !elements || !isPaymentElementReady) { + toast(defaultErrorToast(readyErrorMessage)) + return + } + + setIsSubmitting(true) + + try { + await onSubmit({ stripe, elements }) + } finally { + setIsSubmitting(false) + } + } + + const isFormProcessing = isProcessing || isSubmitting + + return ( +
+ {alert} + + {!isPaymentElementReady && } + + { + setIsPaymentElementReady(true) + }} + onLoadError={(event) => { + setIsPaymentElementReady(false) + onLoadError?.(event.error) + }} + options={{ + layout: { + type: 'tabs', + defaultCollapsed: paymentElementDefaultCollapsed, + }, + }} + /> + + {processingFallbackMessage && isFormProcessing ? ( + + ) : ( + + )} + + ) +} + +interface TeamBlockedRecoveryPaymentElementProps { + clientSecret: string + customerSessionClientSecret?: string + onClose: () => void + confirmPayment: (params: { + stripe: Stripe + elements: StripeElements + returnUrl: string + }) => Promise<{ error?: StripeError }> + alert: ReactNode + loadingMessage: string + submitLabel: ReactNode + processingLabel: string + submittedToast: ToastInput + successToast: ToastInput + onSuccess?: () => void + errorMessages: { + ready: string + confirm: string + stillBlocked: string + statusCheck: string + load: string + } +} + +export const TeamBlockedRecoveryPaymentElement = ({ + clientSecret, + customerSessionClientSecret, + onClose, + confirmPayment, + alert, + loadingMessage, + submitLabel, + processingLabel, + submittedToast, + successToast, + onSuccess, + errorMessages, +}: TeamBlockedRecoveryPaymentElementProps) => { + const router = useRouter() + const { toast } = useToast() + const pollUntilTeamUnblocked = useTeamUnblockPolling() + const [isCheckingTeamStatus, setIsCheckingTeamStatus] = useState(false) + + const handleSubmit = async ({ + stripe, + elements, + }: { + stripe: Stripe + elements: StripeElements + }) => { + const { error } = await confirmPayment({ + stripe, + elements, + returnUrl: window.location.href, + }) + + if (error) { + toast(defaultErrorToast(error.message ?? errorMessages.confirm)) + return + } + + toast(submittedToast) + setIsCheckingTeamStatus(true) + + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast(defaultErrorToast(errorMessages.stillBlocked)) + return + } + + toast(successToast) + onSuccess?.() + onClose() + router.refresh() + } catch { + toast(defaultErrorToast(errorMessages.statusCheck)) + } finally { + setIsCheckingTeamStatus(false) + } + } + + return ( + + { + toast(defaultErrorToast(error.message ?? errorMessages.load)) + onClose() + }} + /> + + ) +} diff --git a/src/features/dashboard/team-blocked/verification-required-dialog.tsx b/src/features/dashboard/team-blocked/verification-required-dialog.tsx new file mode 100644 index 000000000..4baf10a6c --- /dev/null +++ b/src/features/dashboard/team-blocked/verification-required-dialog.tsx @@ -0,0 +1,275 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { parseAsString, useQueryStates } from 'nuqs' +import { usePostHog } from 'posthog-js/react' +import { useCallback, useState } from 'react' +import { useSessionStorage } from 'usehooks-ts' +import type { VerificationPaymentResponse } from '@/core/modules/billing/models' +import { TEAM_BLOCKED_REASONS } from '@/core/modules/teams/constants' +import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { formatCurrency } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' +import { Alert, AlertDescription } from '@/ui/primitives/alert' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { CardIcon } from '@/ui/primitives/icons' +import { useDashboard } from '../context' +import { getBlockedDialogStorageKey } from './team-blocked-dialog-storage' +import { + LoadingState, + TeamBlockedRecoveryPaymentElement, + useStripeReturnHandler, + useTeamUnblockPolling, +} from './team-blocked-recovery' + +const VERIFICATION_PAYMENT_LOADING_MESSAGE = 'Loading verification payment...' +const VERIFICATION_PAYMENT_SUBMITTED_EVENT = 'verification_payment_submitted' + +const stripePaymentIntentParams = { + payment_intent: parseAsString, + payment_intent_client_secret: parseAsString, + redirect_status: parseAsString, +} + +interface VerificationRequiredDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onDismiss: () => void +} + +export const VerificationRequiredDialog = ({ + open, + onOpenChange, + onDismiss, +}: VerificationRequiredDialogProps) => { + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) return onDismiss() + + onOpenChange(nextOpen) + } + + return ( + + + onOpenChange(false)} + /> + + + ) +} + +interface VerificationRequiredDialogContentProps { + open: boolean + onClose: () => void +} + +const VerificationRequiredDialogContent = ({ + open, + onClose, +}: VerificationRequiredDialogContentProps) => { + const { team } = useDashboard() + const { toast } = useToast() + const posthog = usePostHog() + const trpc = useTRPC() + const router = useRouter() + const pollUntilTeamUnblocked = useTeamUnblockPolling() + const [, , removeDismissedDialog] = useSessionStorage( + getBlockedDialogStorageKey(team.slug, TEAM_BLOCKED_REASONS.verification), + false + ) + const [verificationPayment, setVerificationPayment] = + useState(null) + const [isLoadingVerificationPayment, setIsLoadingVerificationPayment] = + useState(false) + const [paymentIntentParams, setPaymentIntentParams] = useQueryStates( + stripePaymentIntentParams, + { + history: 'replace', + shallow: true, + } + ) + + const verificationPaymentMutation = useMutation( + trpc.billing.createVerificationPayment.mutationOptions({ + onError: (error) => { + toast( + defaultErrorToast( + error.message || 'Failed to load verification payment form.' + ) + ) + onClose() + }, + }) + ) + + const createVerificationPayment = useCallback(async () => { + setIsLoadingVerificationPayment(true) + + try { + const payment = await verificationPaymentMutation.mutateAsync({ + teamSlug: team.slug, + }) + setVerificationPayment(payment) + } catch { + // The mutation onError handler owns the user-facing toast and close. + } finally { + setIsLoadingVerificationPayment(false) + } + }, [verificationPaymentMutation.mutateAsync, team.slug]) + + useStripeReturnHandler({ + open, + clientSecret: paymentIntentParams.payment_intent_client_secret, + clearReturnParams: () => { + setPaymentIntentParams({ + payment_intent: null, + payment_intent_client_secret: null, + redirect_status: null, + }) + }, + createPaymentSession: createVerificationPayment, + retrieveStatus: async (stripe, clientSecret) => { + const { paymentIntent, error } = + await stripe.retrievePaymentIntent(clientSecret) + + return { + status: paymentIntent?.status, + errorMessage: error?.message, + } + }, + onSucceeded: async () => { + toast({ + title: 'Verification payment submitted', + description: + 'We are checking whether your team has been verified and unblocked.', + }) + + try { + const isTeamUnblocked = await pollUntilTeamUnblocked() + + if (!isTeamUnblocked) { + toast( + defaultErrorToast( + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.' + ) + ) + router.refresh() + onClose() + return + } + + toast({ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }) + posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) + removeDismissedDialog() + onClose() + router.refresh() + return + } catch { + toast( + defaultErrorToast( + 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.' + ) + ) + } + + router.refresh() + onClose() + }, + onProcessing: () => { + toast({ + title: 'Verification payment processing', + description: + 'Your bank is still processing this payment. Please check again in a moment.', + }) + router.refresh() + onClose() + }, + requiresPaymentMethodMessage: + 'Verification payment was not completed. Please try again.', + retrieveErrorMessage: 'Failed to check verification payment status.', + fallbackErrorMessage: 'Failed to check verification payment status.', + }) + + const paymentAmountLabel = verificationPayment + ? formatCurrency(verificationPayment.amount_due_cents / 100) + : null + + return ( + <> + + Verify team + + This team is blocked because verification is required. Make a + verification payment to continue using E2B. + + + + {isLoadingVerificationPayment ? ( + + ) : verificationPayment ? ( + + stripe.confirmPayment({ + elements, + redirect: 'if_required', + confirmParams: { + return_url: returnUrl, + }, + }) + } + alert={ + + + + A {paymentAmountLabel} card payment will be charged and added + back to your team as credits. + + + } + loadingMessage={VERIFICATION_PAYMENT_LOADING_MESSAGE} + submitLabel={`Pay ${paymentAmountLabel} and verify`} + processingLabel="Processing..." + submittedToast={{ + title: 'Verification payment submitted', + description: + 'We are checking whether your team has been verified and unblocked.', + }} + successToast={{ + variant: 'success', + title: 'Team unblocked', + description: 'Your team has been verified and unblocked.', + }} + onSuccess={() => { + posthog.capture(VERIFICATION_PAYMENT_SUBMITTED_EVENT) + removeDismissedDialog() + }} + errorMessages={{ + ready: 'Payment form is still loading.', + confirm: + 'Failed to process verification payment. Please try again.', + stillBlocked: + 'Verification payment submitted, but your team is still blocked. Please wait a moment and try again.', + statusCheck: + 'Verification payment submitted, but we could not check your team status. Please refresh or try again in a moment.', + load: 'Failed to load payment details. Please refresh and try again.', + }} + /> + ) : null} + + ) +} diff --git a/tests/unit/billing-repository.test.ts b/tests/unit/billing-repository.test.ts new file mode 100644 index 000000000..60a0e5c18 --- /dev/null +++ b/tests/unit/billing-repository.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBillingRepository } from '@/core/modules/billing/repository.server' + +const scope = { + accessToken: 'access-token', + teamId: 'team-id', +} + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +function createRepository() { + return createBillingRepository(scope, { + billingApiUrl: 'https://billing.test', + }) +} + +describe('createBillingRepository', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('creates customer sessions through the payment methods session endpoint', async () => { + const fetch = vi + .fn() + .mockResolvedValue(jsonResponse({ client_secret: 'cs_test' })) + vi.stubGlobal('fetch', fetch) + + const result = await createRepository().getCustomerSession() + + expect(result).toEqual({ + ok: true, + data: { client_secret: 'cs_test' }, + }) + expect(fetch).toHaveBeenCalledWith( + 'https://billing.test/teams/team-id/payment-methods-session', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Supabase-Token': 'access-token', + 'X-Supabase-Team': 'team-id', + }), + }) + ) + }) + + it('requests setup intent details for blocked-team payment recovery', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + client_secret: 'customer_session_secret', + setup_intent_client_secret: 'setup_intent_secret', + }) + ) + vi.stubGlobal('fetch', fetch) + + const result = await createRepository().createPaymentMethodsSession() + + expect(result).toEqual({ + ok: true, + data: { + client_secret: 'customer_session_secret', + setup_intent_client_secret: 'setup_intent_secret', + }, + }) + expect(fetch).toHaveBeenCalledWith( + 'https://billing.test/teams/team-id/payment-methods-session?include_setup_intent=true', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('rejects payment recovery sessions without a setup intent secret', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(jsonResponse({ client_secret: 'customer_secret' })) + ) + + const result = await createRepository().createPaymentMethodsSession() + + expect(result).toEqual({ + ok: false, + error: expect.objectContaining({ + code: 'unavailable', + status: 500, + message: 'Invalid payment methods session response', + }), + }) + }) + + it('creates verification payments for verification-blocked teams', async () => { + const fetch = vi.fn().mockResolvedValue( + jsonResponse({ + client_secret: 'payment_intent_secret', + amount_due_cents: 100, + }) + ) + vi.stubGlobal('fetch', fetch) + + const result = await createRepository().createVerificationPayment() + + expect(result).toEqual({ + ok: true, + data: { + client_secret: 'payment_intent_secret', + amount_due_cents: 100, + }, + }) + expect(fetch).toHaveBeenCalledWith( + 'https://billing.test/teams/team-id/verification-payment', + expect.objectContaining({ method: 'POST' }) + ) + }) +}) diff --git a/tests/unit/team-blocked-message.test.ts b/tests/unit/team-blocked-message.test.ts new file mode 100644 index 000000000..81be3ca97 --- /dev/null +++ b/tests/unit/team-blocked-message.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { getBlockedMessage } from '@/features/dashboard/team-blocked/team-blocked-message' + +describe('getBlockedMessage', () => { + it('links billing limit blocks to limits', () => { + expect(getBlockedMessage('team-slug', 'billing limit reached')).toEqual({ + text: 'Billing limit reached.', + cta: 'Update limit.', + href: '/dashboard/team-slug/limits', + }) + }) + + it('opens the missing payment method recovery dialog', () => { + expect(getBlockedMessage('team-slug', 'missing payment method')).toEqual({ + text: 'Missing payment method.', + cta: 'Add payment method.', + href: null, + }) + }) + + it('opens the verification recovery dialog', () => { + expect(getBlockedMessage('team-slug', 'verification required')).toEqual({ + text: 'Verification required.', + cta: 'Complete verification.', + href: null, + }) + }) + + it('falls back to the backend-provided block reason', () => { + expect(getBlockedMessage('team-slug', 'blocked by support')).toEqual({ + text: 'blocked by support', + cta: null, + href: null, + }) + }) +})