diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index 4454620..1fa95ce 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -1,3 +1,5 @@ +import { ReactNode } from "react"; + import CheckIcon from "@/app/components/icons/CheckIcon"; import SpinnerIcon from "@/app/components/icons/SpinnerIcon"; import XIcon from "@/app/components/icons/XIcon"; @@ -5,7 +7,7 @@ import XIcon from "@/app/components/icons/XIcon"; interface ModalProps { title: string; type: "loading" | "success" | "error"; - message: string; + message: ReactNode; onClose?: () => void; } diff --git a/app/dashboard/coverage/handlers/handle-create-prs.ts b/app/dashboard/coverage/handlers/handle-create-prs.ts index 294533d..f2e4234 100644 --- a/app/dashboard/coverage/handlers/handle-create-prs.ts +++ b/app/dashboard/coverage/handlers/handle-create-prs.ts @@ -1,7 +1,16 @@ +import { getCreditBalance } from "@/app/actions/supabase/owners/get-credit-balance"; import { PRResponse } from "@/app/dashboard/coverage/types"; +import { CREDIT_PRICING } from "@/config/pricing"; +import { Installation } from "@/types/github"; import { Tables } from "@/types/supabase"; import { fetchWithTiming } from "@/utils/fetch"; +export type InsufficientCreditsInfo = { + balance: number; + required: number; + numPRs: number; +}; + /** * Handle creating GitHub PRs for selected coverage items */ @@ -12,11 +21,14 @@ export async function handleCreatePRs({ currentRepoName, accessToken, hasLabel = false, + ownerId, + installations, setCoverageData, setSelectedRows, setActionSuccess, setError, setIsCreatingPRs, + setInsufficientCredits, }: { selectedRows: number[]; coverageData: Tables<"coverages">[]; @@ -24,14 +36,29 @@ export async function handleCreatePRs({ currentRepoName: string; accessToken: string; hasLabel?: boolean; + ownerId: number; + installations: Installation[] | undefined; setCoverageData: (fn: (prev: Tables<"coverages">[]) => Tables<"coverages">[]) => void; setSelectedRows: (rows: number[]) => void; setActionSuccess: (success: boolean) => void; setError: (error: string) => void; setIsCreatingPRs: (loading: boolean) => void; + setInsufficientCredits: (info: InsufficientCreditsInfo | null) => void; }) { if (selectedRows.length === 0) return; + // Pre-flight credit check: skip for subscription users + const currentInstallation = installations?.find((inst) => inst.owner_id === ownerId); + if (!currentInstallation?.hasActiveSubscription) { + const balance = await getCreditBalance(ownerId); + const required = CREDIT_PRICING.PER_PR.AMOUNT_USD * selectedRows.length; + if (balance < required) { + console.log("Insufficient credits:", { balance, required, numPRs: selectedRows.length }); + setInsufficientCredits({ balance, required, numPRs: selectedRows.length }); + return; + } + } + setIsCreatingPRs(true); try { const selectedCoverages = coverageData.filter((item) => selectedRows.includes(item.id)); @@ -48,7 +75,7 @@ export async function handleCreatePRs({ accessToken, hasLabel, }), - } + }, ); // Update local state with PR URLs @@ -57,7 +84,7 @@ export async function handleCreatePRs({ const pr = prs.find((p) => p.coverageId === item.id); if (pr) return { ...item, github_issue_url: pr.prUrl }; return item; - }) + }), ); setSelectedRows([]); @@ -67,7 +94,7 @@ export async function handleCreatePRs({ setError( typeof error === "object" && error !== null && "message" in error ? String(error.message) - : "Failed to create PRs" + : "Failed to create PRs", ); } finally { setIsCreatingPRs(false); diff --git a/app/dashboard/coverage/page.tsx b/app/dashboard/coverage/page.tsx index 928ec9c..f6d89c6 100644 --- a/app/dashboard/coverage/page.tsx +++ b/app/dashboard/coverage/page.tsx @@ -17,6 +17,7 @@ import LoadingSpinner from "@/app/components/LoadingSpinner"; import Modal from "@/app/components/Modal"; import Toast from "@/app/components/Toast"; import RepositorySelector from "@/app/settings/components/RepositorySelector"; +import { CREDIT_PRICING } from "@/config/pricing"; import { RELATIVE_URLS } from "@/config/urls"; import { STORAGE_KEYS } from "@/lib/constants"; import { safeLocalStorage } from "@/lib/local-storage"; @@ -37,7 +38,7 @@ import { } from "./constants/filter-options"; import { SYNC_MESSAGES } from "./constants/sync-messages"; import { fetchCoverageData } from "./handlers/fetch-coverage-data"; -import { handleCreatePRs } from "./handlers/handle-create-prs"; +import { handleCreatePRs, InsufficientCreditsInfo } from "./handlers/handle-create-prs"; import { handleSelectAll } from "./handlers/handle-select-all"; import { handleSelectRow } from "./handlers/handle-select-row"; import { handleSort } from "./handlers/handle-sort"; @@ -55,6 +56,7 @@ export default function CoveragePage() { organizations, accessToken, currentInstallationId, + installations, userId, userLogin, userName, @@ -96,6 +98,9 @@ export default function CoveragePage() { const [isTogglingExclusion, setIsTogglingExclusion] = useState(false); const [isSettingUpWorkflow, setIsSettingUpWorkflow] = useState(false); const [showSetupModal, setShowSetupModal] = useState(false); + const [insufficientCredits, setInsufficientCredits] = useState( + null, + ); // Load sort settings from localStorage useEffect(() => { @@ -383,7 +388,7 @@ export default function CoveragePage() { selectedRows={selectedRows} isCreatingPRs={isCreatingPRs} onCreatePRs={(hasLabel) => { - if (!currentOwnerName || !currentRepoName || !accessToken) { + if (!currentOwnerName || !currentRepoName || !accessToken || !currentOwnerId) { setError("Missing required repository information"); return; } @@ -395,11 +400,14 @@ export default function CoveragePage() { currentRepoName, accessToken, hasLabel, + ownerId: currentOwnerId, + installations, setCoverageData, setSelectedRows, setActionSuccess, setError, setIsCreatingPRs, + setInsufficientCredits, }); }} onToggleExclusion={handleToggleExclusion} @@ -487,6 +495,25 @@ export default function CoveragePage() { /> )} + {insufficientCredits && ( + + Your balance is ${insufficientCredits.balance.toFixed(2)}, but creating{" "} + {insufficientCredits.numPRs} PR{insufficientCredits.numPRs === 1 ? "" : "s"} requires + ${insufficientCredits.required.toFixed(2)} (${CREDIT_PRICING.PER_PR.AMOUNT_USD}/PR).{" "} + + Purchase credits + {" "} + to continue. + + } + onClose={() => setInsufficientCredits(null)} + /> + )} + {/* If no data exists: show sync status or last sync time */} {!isLoadingDB && coverageData.length === 0 && gitHubSyncStatus && (