diff --git a/api/routes/organization_usage.py b/api/routes/organization_usage.py index 8e75a2c8..182456a5 100644 --- a/api/routes/organization_usage.py +++ b/api/routes/organization_usage.py @@ -7,10 +7,13 @@ from fastapi.responses import StreamingResponse from loguru import logger from pydantic import BaseModel, Field -from api.constants import DEPLOYMENT_MODE +from api.constants import DEPLOYMENT_MODE, UI_APP_URL from api.db import db_client from api.db.models import UserModel -from api.services.auth.depends import get_user +from api.services.auth.depends import get_user, get_user_with_selected_organization +from api.services.configuration.ai_model_configuration import ( + get_resolved_ai_model_configuration, +) from api.services.mps_service_key_client import mps_service_key_client from api.services.reports import generate_usage_runs_report_csv from api.utils.artifacts import artifact_url @@ -40,6 +43,10 @@ class MPSCreditsResponse(BaseModel): total_quota: float +class MPSCreditPurchaseUrlResponse(BaseModel): + checkout_url: str + + class WorkflowRunUsageResponse(BaseModel): id: int workflow_id: int @@ -142,6 +149,66 @@ async def get_mps_credits(user: UserModel = Depends(get_user)): raise HTTPException(status_code=500, detail=str(e)) +@router.post( + "/usage/mps-credits/purchase-url", + response_model=MPSCreditPurchaseUrlResponse, +) +async def create_mps_credit_purchase_url( + user: UserModel = Depends(get_user_with_selected_organization), +): + """Create a checkout URL for organizations using Dograh-managed MPS v2.""" + if DEPLOYMENT_MODE == "oss": + raise HTTPException( + status_code=404, + detail="Credit purchases are not available in OSS mode", + ) + + organization_id = user.selected_organization_id + assert organization_id is not None + resolved = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=organization_id, + ) + if ( + resolved.source != "organization_v2" + or resolved.effective.managed_service_version != 2 + ): + raise HTTPException( + status_code=403, + detail=( + "Credit purchases are available only for organizations using " + "Dograh managed model configuration v2" + ), + ) + + try: + session = await mps_service_key_client.create_credit_purchase_url( + organization_id=organization_id, + created_by=str(user.provider_id), + return_url=f"{UI_APP_URL.rstrip('/')}/reports", + billing_details={ + "source": "dograh_reports", + "dograh_user_id": str(user.id), + "dograh_provider_id": str(user.provider_id), + }, + ) + except Exception as exc: + logger.error(f"Failed to create MPS credit purchase URL: {exc}") + raise HTTPException( + status_code=502, + detail="Failed to create credit purchase URL", + ) + + checkout_url = session.get("checkout_url") + if not checkout_url: + logger.error(f"MPS checkout session response missing checkout_url: {session}") + raise HTTPException( + status_code=502, + detail="MPS checkout session response missing checkout_url", + ) + return MPSCreditPurchaseUrlResponse(checkout_url=checkout_url) + + FILTERS_DESCRIPTION = """\ JSON-encoded array of filter objects. Each object has the shape: diff --git a/api/services/mps_service_key_client.py b/api/services/mps_service_key_client.py index f7ce749f..bd4073cb 100644 --- a/api/services/mps_service_key_client.py +++ b/api/services/mps_service_key_client.py @@ -353,6 +353,43 @@ class MPSServiceKeyClient: response=response, ) + async def create_credit_purchase_url( + self, + organization_id: int, + created_by: Optional[str] = None, + return_url: Optional[str] = None, + billing_details: Optional[dict] = None, + ) -> dict: + """Create a short-lived MPS checkout URL for adding organization credits.""" + payload = { + "created_by": created_by, + "return_url": return_url, + "billing_details": billing_details or {}, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/checkout-sessions", + json=payload, + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to create MPS credit purchase URL: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to create MPS credit purchase URL: {response.text}", + request=response.request, + response=response, + ) + async def create_correlation_id( self, *, diff --git a/ui/src/app/reports/page.tsx b/ui/src/app/reports/page.tsx index 770e1724..debee16b 100644 --- a/ui/src/app/reports/page.tsx +++ b/ui/src/app/reports/page.tsx @@ -1,12 +1,14 @@ 'use client'; import { addDays, format, subDays } from 'date-fns'; -import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react'; -import { useEffect,useState } from 'react'; +import { Calendar, ChevronLeft, ChevronRight, CreditCard, Download, Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { client } from '@/client/client.gen'; import { getDailyReportApiV1OrganizationsReportsDailyGet, getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet, + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get, getPreferencesApiV1OrganizationsPreferencesGet, getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet } from '@/client/sdk.gen'; @@ -17,6 +19,7 @@ import { Card } from '@/components/ui/card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; +import { detailFromError } from '@/lib/apiError'; import { useAuth } from '@/lib/auth'; import { DispositionChart } from './components/DispositionChart'; @@ -50,6 +53,10 @@ interface DailyReport { }>; } +type CreditPurchaseUrlResponse = { + checkout_url: string; +}; + export default function ReportsPage() { const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedWorkflow, setSelectedWorkflow] = useState('all'); @@ -57,6 +64,9 @@ export default function ReportsPage() { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [canBuyCredits, setCanBuyCredits] = useState(false); + const [purchaseLoading, setPurchaseLoading] = useState(false); + const [purchaseError, setPurchaseError] = useState(null); const [timezone, setTimezone] = useState('America/New_York'); const auth = useAuth(); @@ -94,6 +104,50 @@ export default function ReportsPage() { fetchPreferences(); }, [auth.isAuthenticated]); + useEffect(() => { + if (auth.loading) return; + + if (!auth.isAuthenticated) { + setCanBuyCredits(false); + return; + } + + let cancelled = false; + + const fetchCreditPurchaseAvailability = async () => { + try { + const response = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(); + if (cancelled) return; + + if (response.error || !response.data) { + setCanBuyCredits(false); + return; + } + + const configuration = response.data.configuration as { + mode?: unknown; + dograh?: unknown; + } | null; + setCanBuyCredits( + response.data.source === 'organization_v2' && + configuration?.mode === 'dograh' && + Boolean(configuration.dograh) + ); + } catch (err) { + console.error('Failed to check credit purchase availability:', err); + if (!cancelled) { + setCanBuyCredits(false); + } + } + }; + + fetchCreditPurchaseAvailability(); + + return () => { + cancelled = true; + }; + }, [auth.loading, auth.isAuthenticated]); + // Fetch report data when date or workflow changes useEffect(() => { const fetchReport = async () => { @@ -195,13 +249,70 @@ export default function ReportsPage() { } }; + const handleBuyCredits = async () => { + if (!auth.isAuthenticated || purchaseLoading) return; + + setPurchaseLoading(true); + setPurchaseError(null); + + try { + const response = await client.post< + { 200: CreditPurchaseUrlResponse }, + { detail?: unknown } + >({ + url: '/api/v1/organizations/usage/mps-credits/purchase-url', + }); + + if (response.error) { + throw new Error( + detailFromError(response.error, 'Failed to create credit purchase URL') + ); + } + + const checkoutUrl = response.data?.checkout_url; + if (!checkoutUrl) { + throw new Error('Failed to create credit purchase URL'); + } + + window.location.href = checkoutUrl; + } catch (err) { + console.error('Failed to create credit purchase URL:', err); + setPurchaseError( + err instanceof Error ? err.message : 'Failed to create credit purchase URL' + ); + setPurchaseLoading(false); + } + }; + const isToday = format(selectedDate, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd'); return (
{/* Header */}
-

Daily Reports

+
+
+

Daily Reports

+ {canBuyCredits && ( + + )} +
+ {purchaseError && ( +

{purchaseError}

+ )} +
{/* Date Navigation & Workflow Selector */}