Billing and account fixed - final
This commit is contained in:
0
docs/user-flow/user-flow-initial
Normal file
0
docs/user-flow/user-flow-initial
Normal file
@@ -67,23 +67,23 @@ const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPa
|
|||||||
const TeamManagementPage = lazy(() => import("./pages/account/TeamManagementPage"));
|
const TeamManagementPage = lazy(() => import("./pages/account/TeamManagementPage"));
|
||||||
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage"));
|
||||||
|
|
||||||
// Admin Module - Lazy loaded
|
// Admin Module - Lazy loaded (mixed folder casing in repo, match actual file paths)
|
||||||
const AdminBilling = lazy(() => import("./pages/Admin/AdminBilling"));
|
const AdminBilling = lazy(() => import("./pages/Admin/AdminBilling"));
|
||||||
const PaymentApprovalPage = lazy(() => import("./pages/Admin/PaymentApprovalPage.tsx"));
|
const PaymentApprovalPage = lazy(() => import("./pages/admin/PaymentApprovalPage"));
|
||||||
const AdminSystemDashboard = lazy(() => import("./pages/Admin/AdminSystemDashboard"));
|
const AdminSystemDashboard = lazy(() => import("./pages/admin/AdminSystemDashboard"));
|
||||||
const AdminAllAccountsPage = lazy(() => import("./pages/Admin/AdminAllAccountsPage"));
|
const AdminAllAccountsPage = lazy(() => import("./pages/admin/AdminAllAccountsPage"));
|
||||||
const AdminSubscriptionsPage = lazy(() => import("./pages/Admin/AdminSubscriptionsPage"));
|
const AdminSubscriptionsPage = lazy(() => import("./pages/admin/AdminSubscriptionsPage"));
|
||||||
const AdminAccountLimitsPage = lazy(() => import("./pages/Admin/AdminAccountLimitsPage"));
|
const AdminAccountLimitsPage = lazy(() => import("./pages/admin/AdminAccountLimitsPage"));
|
||||||
const AdminAllInvoicesPage = lazy(() => import("./pages/Admin/AdminAllInvoicesPage"));
|
const AdminAllInvoicesPage = lazy(() => import("./pages/admin/AdminAllInvoicesPage"));
|
||||||
const AdminAllPaymentsPage = lazy(() => import("./pages/Admin/AdminAllPaymentsPage"));
|
const AdminAllPaymentsPage = lazy(() => import("./pages/admin/AdminAllPaymentsPage"));
|
||||||
const AdminCreditPackagesPage = lazy(() => import("./pages/Admin/AdminCreditPackagesPage"));
|
const AdminCreditPackagesPage = lazy(() => import("./pages/admin/AdminCreditPackagesPage"));
|
||||||
const AdminCreditCostsPage = lazy(() => import("./pages/Admin/AdminCreditCostsPage"));
|
const AdminCreditCostsPage = lazy(() => import("./pages/Admin/AdminCreditCostsPage"));
|
||||||
const AdminAllUsersPage = lazy(() => import("./pages/Admin/AdminAllUsersPage"));
|
const AdminAllUsersPage = lazy(() => import("./pages/admin/AdminAllUsersPage"));
|
||||||
const AdminRolesPermissionsPage = lazy(() => import("./pages/Admin/AdminRolesPermissionsPage"));
|
const AdminRolesPermissionsPage = lazy(() => import("./pages/admin/AdminRolesPermissionsPage"));
|
||||||
const AdminActivityLogsPage = lazy(() => import("./pages/Admin/AdminActivityLogsPage"));
|
const AdminActivityLogsPage = lazy(() => import("./pages/admin/AdminActivityLogsPage"));
|
||||||
const AdminSystemSettingsPage = lazy(() => import("./pages/Admin/AdminSystemSettingsPage"));
|
const AdminSystemSettingsPage = lazy(() => import("./pages/admin/AdminSystemSettingsPage"));
|
||||||
const AdminSystemHealthPage = lazy(() => import("./pages/Admin/AdminSystemHealthPage"));
|
const AdminSystemHealthPage = lazy(() => import("./pages/admin/AdminSystemHealthPage"));
|
||||||
const AdminAPIMonitorPage = lazy(() => import("./pages/Admin/AdminAPIMonitorPage"));
|
const AdminAPIMonitorPage = lazy(() => import("./pages/admin/AdminAPIMonitorPage"));
|
||||||
|
|
||||||
// Reference Data - Lazy loaded
|
// Reference Data - Lazy loaded
|
||||||
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const endpointGroups = [
|
|||||||
{ path: "/v1/system/strategies/", method: "GET" },
|
{ path: "/v1/system/strategies/", method: "GET" },
|
||||||
{ path: "/v1/system/settings/integrations/openai/test/", method: "POST" },
|
{ path: "/v1/system/settings/integrations/openai/test/", method: "POST" },
|
||||||
{ path: "/v1/system/settings/account/", method: "GET" },
|
{ path: "/v1/system/settings/account/", method: "GET" },
|
||||||
{ path: "/v1/billing/credits/balance/balance/", method: "GET" },
|
{ path: "/v1/billing/credits/balance/", method: "GET" },
|
||||||
{ path: "/v1/billing/credits/usage/", method: "GET" },
|
{ path: "/v1/billing/credits/usage/", method: "GET" },
|
||||||
{ path: "/v1/billing/credits/usage/summary/", method: "GET" },
|
{ path: "/v1/billing/credits/usage/summary/", method: "GET" },
|
||||||
{ path: "/v1/billing/credits/transactions/", method: "GET" },
|
{ path: "/v1/billing/credits/transactions/", method: "GET" },
|
||||||
|
|||||||
@@ -21,23 +21,39 @@ import {
|
|||||||
|
|
||||||
interface UserAccount {
|
interface UserAccount {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
account_name?: string;
|
||||||
credits: number;
|
credits: number;
|
||||||
subscription_plan: string;
|
subscription_plan?: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditCostConfig {
|
interface CreditCostConfig {
|
||||||
id: number;
|
id: number;
|
||||||
model_name: string;
|
|
||||||
operation_type: string;
|
operation_type: string;
|
||||||
cost: number;
|
display_name: string;
|
||||||
|
credits_cost: number;
|
||||||
|
unit?: string;
|
||||||
|
description?: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreditPackageItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
credits: number;
|
||||||
|
price: string;
|
||||||
|
discount_percentage: number;
|
||||||
|
is_featured: boolean;
|
||||||
|
description?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SystemStats {
|
interface SystemStats {
|
||||||
total_users: number;
|
total_users: number;
|
||||||
active_users: number;
|
active_users: number;
|
||||||
@@ -50,8 +66,9 @@ const AdminBilling: React.FC = () => {
|
|||||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||||
const [users, setUsers] = useState<UserAccount[]>([]);
|
const [users, setUsers] = useState<UserAccount[]>([]);
|
||||||
const [creditConfigs, setCreditConfigs] = useState<CreditCostConfig[]>([]);
|
const [creditConfigs, setCreditConfigs] = useState<CreditCostConfig[]>([]);
|
||||||
|
const [creditPackages, setCreditPackages] = useState<CreditPackageItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'pricing'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'pricing' | 'packages'>('overview');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<UserAccount | null>(null);
|
const [selectedUser, setSelectedUser] = useState<UserAccount | null>(null);
|
||||||
const [creditAmount, setCreditAmount] = useState('');
|
const [creditAmount, setCreditAmount] = useState('');
|
||||||
@@ -65,17 +82,19 @@ const AdminBilling: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [statsData, usersData, configsData] = await Promise.all([
|
const [statsData, usersData, configsData] = await Promise.all([
|
||||||
// Admin billing stats (credits, activity, revenue)
|
// Admin billing stats (modules admin endpoints)
|
||||||
fetchAPI('/v1/billing/admin/stats/'),
|
fetchAPI('/v1/admin/billing/stats/'),
|
||||||
// Admin billing users list (with credits)
|
// Admin users with credits
|
||||||
fetchAPI('/v1/admin/billing/users/?limit=100'),
|
fetchAPI('/v1/admin/users/'),
|
||||||
// Admin billing credit costs
|
// Admin credit costs (modules billing)
|
||||||
fetchAPI('/v1/admin/billing/credit-costs/'),
|
fetchAPI('/v1/admin/credit-costs/'),
|
||||||
]);
|
]);
|
||||||
|
const packagesData = await fetchAPI('/v1/billing/credit-packages/');
|
||||||
|
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
setUsers(usersData.results || []);
|
setUsers(usersData.results || []);
|
||||||
setCreditConfigs(configsData.results || []);
|
setCreditConfigs(configsData.results || []);
|
||||||
|
setCreditPackages(packagesData.results || []);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast?.error(error?.message || 'Failed to load admin data');
|
toast?.error(error?.message || 'Failed to load admin data');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,7 +109,7 @@ const AdminBilling: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchAPI(`/v1/admin/billing/users/${selectedUser.id}/adjust-credits/`, {
|
await fetchAPI(`/v1/admin/users/${selectedUser.id}/adjust-credits/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
amount: parseInt(creditAmount),
|
amount: parseInt(creditAmount),
|
||||||
@@ -110,9 +129,9 @@ const AdminBilling: React.FC = () => {
|
|||||||
|
|
||||||
const handleUpdateCreditCost = async (configId: number, newCost: number) => {
|
const handleUpdateCreditCost = async (configId: number, newCost: number) => {
|
||||||
try {
|
try {
|
||||||
await fetchAPI(`/v1/admin/billing/credit-costs/${configId}/`, {
|
await fetchAPI('/v1/admin/credit-costs/', {
|
||||||
method: 'PATCH',
|
method: 'POST',
|
||||||
body: JSON.stringify({ cost: newCost }),
|
body: JSON.stringify({ updates: [{ id: configId, cost: newCost }] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
toast?.success('Credit cost updated successfully');
|
toast?.success('Credit cost updated successfully');
|
||||||
@@ -123,10 +142,26 @@ const AdminBilling: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter(user =>
|
const filteredUsers = users.filter(user =>
|
||||||
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
(user.email || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
(user.username || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(user.account_name || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatLabel = (value?: string) =>
|
||||||
|
(value || '')
|
||||||
|
.split('_')
|
||||||
|
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : ''))
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const updateLocalCost = (id: number, value: string) => {
|
||||||
|
setCreditConfigs((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === id ? { ...c, credits_cost: value === '' ? ('' as any) : Number(value) } : c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -220,6 +255,16 @@ const AdminBilling: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Credit Pricing ({creditConfigs.length})
|
Credit Pricing ({creditConfigs.length})
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('packages')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'packages'
|
||||||
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Credit Packages ({creditPackages.length})
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -399,19 +444,22 @@ const AdminBilling: React.FC = () => {
|
|||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
||||||
Model
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
Operation
|
Operation
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Display Name
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Credits Cost
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Unit
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
Cost (Credits)
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -419,27 +467,34 @@ const AdminBilling: React.FC = () => {
|
|||||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{creditConfigs.map((config) => (
|
{creditConfigs.map((config) => (
|
||||||
<tr key={config.id}>
|
<tr key={config.id}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||||
{config.model_name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{config.operation_type}
|
{config.operation_type}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-bold text-amber-600 dark:text-amber-400">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
{config.cost}
|
{config.display_name || formatLabel(config.operation_type)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4">
|
||||||
<Badge tone={config.is_active ? 'success' : 'warning'}>
|
<input
|
||||||
{config.is_active ? 'Active' : 'Inactive'}
|
type="number"
|
||||||
</Badge>
|
value={config.credits_cost as any}
|
||||||
|
onChange={(e) => updateLocalCost(config.id, e.target.value)}
|
||||||
|
className="w-24 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatLabel(config.unit)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400 max-w-md">
|
||||||
|
{config.description || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open(`/admin/igny8_core/creditcostconfig/${config.id}/change/`, '_blank')}
|
variant="primary"
|
||||||
|
tone="brand"
|
||||||
|
onClick={() => handleUpdateCreditCost(config.id, Number(config.credits_cost) || 0)}
|
||||||
>
|
>
|
||||||
Edit
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -447,16 +502,37 @@ const AdminBilling: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
</ComponentCard>
|
||||||
To add new credit costs or modify these settings, use the{' '}
|
)}
|
||||||
<a
|
|
||||||
href="/admin/igny8_core/creditcostconfig/"
|
{activeTab === 'packages' && (
|
||||||
target="_blank"
|
<ComponentCard title="Credit Packages">
|
||||||
rel="noopener noreferrer"
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
{creditPackages.map((pkg) => (
|
||||||
>
|
<ComponentCard key={pkg.id} title={pkg.name} desc={pkg.description || ''}>
|
||||||
Django Admin Panel
|
<div className="space-y-3">
|
||||||
</a>
|
<div className="text-3xl font-bold text-blue-600">{pkg.credits.toLocaleString()}</div>
|
||||||
|
<div className="text-sm text-gray-500">credits</div>
|
||||||
|
<div className="text-2xl font-semibold text-gray-900 dark:text-white">${pkg.price}</div>
|
||||||
|
{pkg.discount_percentage > 0 && (
|
||||||
|
<div className="text-sm text-green-600">Save {pkg.discount_percentage}%</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="light" color={pkg.is_active ? 'success' : 'warning'}>
|
||||||
|
{pkg.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
{pkg.is_featured && (
|
||||||
|
<Badge variant="light" color="primary">
|
||||||
|
Featured
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
))}
|
||||||
|
{creditPackages.length === 0 && (
|
||||||
|
<div className="col-span-3 text-center py-8 text-gray-500">No credit packages found.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ComponentCard>
|
</ComponentCard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -158,164 +158,4 @@ export default function AdminCreditCostsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Admin Credit Costs Page
|
|
||||||
* Manage credit pricing per billable action
|
|
||||||
*/
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { AlertCircle, Loader2, Check } from 'lucide-react';
|
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
|
||||||
import { Card } from '../../components/ui/card';
|
|
||||||
import Button from '../../components/ui/button/Button';
|
|
||||||
import {
|
|
||||||
getCreditCosts,
|
|
||||||
updateCreditCosts,
|
|
||||||
type CreditCostConfig,
|
|
||||||
} from '../../services/billing.api';
|
|
||||||
|
|
||||||
export default function AdminCreditCostsPage() {
|
|
||||||
const [costs, setCosts] = useState<CreditCostConfig[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [savingId, setSavingId] = useState<number | null>(null);
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCosts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadCosts = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await getCreditCosts();
|
|
||||||
setCosts(data.results || []);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Failed to load credit costs');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (cost: CreditCostConfig) => {
|
|
||||||
try {
|
|
||||||
setSavingId(cost.id);
|
|
||||||
await updateCreditCosts([
|
|
||||||
{ operation_type: cost.operation_type, credits_cost: Number(cost.credits_cost) || 0 },
|
|
||||||
]);
|
|
||||||
await loadCosts();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Failed to update credit cost');
|
|
||||||
} finally {
|
|
||||||
setSavingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLocalCost = (id: number, value: string) => {
|
|
||||||
setCosts((prev) =>
|
|
||||||
prev.map((c) => (c.id === id ? { ...c, credits_cost: value as any } : c)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 flex items-center justify-center min-h-screen">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<PageMeta title="Admin - Credit Costs" description="Manage credit pricing per action" />
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Credit Costs</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Configure credits required for each billable operation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-3">
|
|
||||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
|
||||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Operation
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Display Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Credits Cost
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Unit
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{costs.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
|
||||||
No credit costs configured
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
costs.map((cost) => (
|
|
||||||
<tr key={cost.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
||||||
<td className="px-6 py-4 text-sm font-mono text-gray-700 dark:text-gray-300">
|
|
||||||
{cost.operation_type}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
|
||||||
{cost.display_name || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={cost.credits_cost as any}
|
|
||||||
onChange={(e) => updateLocalCost(cost.id, e.target.value)}
|
|
||||||
className="w-24 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-white"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{cost.unit}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400 max-w-md">
|
|
||||||
{cost.description}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primary"
|
|
||||||
tone="brand"
|
|
||||||
startIcon={savingId === cost.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
|
||||||
disabled={savingId === cost.id}
|
|
||||||
onClick={() => handleSave(cost)}
|
|
||||||
>
|
|
||||||
{savingId === cost.id ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const endpointGroups: EndpointGroup[] = [
|
|||||||
{ path: "/v1/system/strategies/", method: "GET", description: "List strategies" },
|
{ path: "/v1/system/strategies/", method: "GET", description: "List strategies" },
|
||||||
{ path: "/v1/system/settings/integrations/openai/test/", method: "POST", description: "Test integration (OpenAI)" },
|
{ path: "/v1/system/settings/integrations/openai/test/", method: "POST", description: "Test integration (OpenAI)" },
|
||||||
{ path: "/v1/system/settings/account/", method: "GET", description: "Account settings" },
|
{ path: "/v1/system/settings/account/", method: "GET", description: "Account settings" },
|
||||||
{ path: "/v1/billing/credits/balance/balance/", method: "GET", description: "Credit balance" },
|
{ path: "/v1/billing/credits/balance/", method: "GET", description: "Credit balance" },
|
||||||
{ path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" },
|
{ path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" },
|
||||||
{ path: "/v1/billing/credits/usage/summary/", method: "GET", description: "Usage summary" },
|
{ path: "/v1/billing/credits/usage/summary/", method: "GET", description: "Usage summary" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Plus, Loader2, AlertCircle, Edit, Trash } from 'lucide-react';
|
import { Plus, Loader2, AlertCircle, Edit, Trash } from 'lucide-react';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
import { getCreditPackages, type CreditPackage } from '../../services/billing.api';
|
import { getAdminCreditPackages, type CreditPackage } from '../../services/billing.api';
|
||||||
|
|
||||||
export default function AdminCreditPackagesPage() {
|
export default function AdminCreditPackagesPage() {
|
||||||
const [packages, setPackages] = useState<CreditPackage[]>([]);
|
const [packages, setPackages] = useState<CreditPackage[]>([]);
|
||||||
@@ -21,7 +21,7 @@ export default function AdminCreditPackagesPage() {
|
|||||||
const loadPackages = async () => {
|
const loadPackages = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getCreditPackages();
|
const data = await getAdminCreditPackages();
|
||||||
setPackages(data.results || []);
|
setPackages(data.results || []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load credit packages');
|
setError(err.message || 'Failed to load credit packages');
|
||||||
|
|||||||
@@ -1743,12 +1743,12 @@ export interface UsageSummary {
|
|||||||
|
|
||||||
export async function fetchCreditBalance(): Promise<CreditBalance> {
|
export async function fetchCreditBalance(): Promise<CreditBalance> {
|
||||||
try {
|
try {
|
||||||
const response = await fetchAPI('/v1/billing/credits/balance/balance/');
|
// Canonical balance endpoint (business billing CreditTransactionViewSet.balance)
|
||||||
// fetchAPI automatically extracts data field from unified format
|
const response = await fetchAPI('/v1/billing/transactions/balance/');
|
||||||
if (response && typeof response === 'object' && 'credits' in response) {
|
if (response && typeof response === 'object' && 'credits' in response) {
|
||||||
return response as CreditBalance;
|
return response as CreditBalance;
|
||||||
}
|
}
|
||||||
// Return default if response is invalid
|
// Default if response is invalid
|
||||||
return {
|
return {
|
||||||
credits: 0,
|
credits: 0,
|
||||||
plan_credits_per_month: 0,
|
plan_credits_per_month: 0,
|
||||||
@@ -1756,7 +1756,7 @@ export async function fetchCreditBalance(): Promise<CreditBalance> {
|
|||||||
credits_remaining: 0,
|
credits_remaining: 0,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.warn('Failed to fetch credit balance, using defaults:', error.message);
|
console.debug('Failed to fetch credit balance, using defaults:', error?.message || error);
|
||||||
// Return default balance on error so UI can still render
|
// Return default balance on error so UI can still render
|
||||||
return {
|
return {
|
||||||
credits: 0,
|
credits: 0,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Billing API Service
|
* Billing API Service
|
||||||
* Uses STANDARD existing billing endpoints from /v1/billing/, /v1/system/, and /v1/admin/
|
* Uses STANDARD billing endpoints from /api/v1/billing and /api/v1/admin/billing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchAPI } from './api';
|
import { fetchAPI } from './api';
|
||||||
@@ -142,6 +142,9 @@ export interface CreditPackage {
|
|||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
stripe_price_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
@@ -188,6 +191,7 @@ export interface PendingPayment extends Payment {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getCreditBalance(): Promise<CreditBalance> {
|
export async function getCreditBalance(): Promise<CreditBalance> {
|
||||||
|
// Use business billing CreditTransactionViewSet.balance
|
||||||
return fetchAPI('/v1/billing/transactions/balance/');
|
return fetchAPI('/v1/billing/transactions/balance/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +200,7 @@ export async function getCreditTransactions(): Promise<{
|
|||||||
count: number;
|
count: number;
|
||||||
current_balance?: number;
|
current_balance?: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/billing/transactions/');
|
return fetchAPI('/api/v1/billing/transactions/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -216,7 +220,7 @@ export async function getCreditUsage(params?: {
|
|||||||
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
if (params?.start_date) queryParams.append('start_date', params.start_date);
|
||||||
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
if (params?.end_date) queryParams.append('end_date', params.end_date);
|
||||||
|
|
||||||
const url = `/v1/billing/credits/usage/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
const url = `/api/v1/billing/credits/usage/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||||
return fetchAPI(url);
|
return fetchAPI(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +265,8 @@ export async function getCreditUsageLimits(): Promise<{
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getAdminBillingStats(): Promise<AdminBillingStats> {
|
export async function getAdminBillingStats(): Promise<AdminBillingStats> {
|
||||||
// Use business billing admin stats endpoint (returns all dashboard metrics)
|
// Admin billing dashboard metrics
|
||||||
return fetchAPI('/v1/billing/admin/stats/');
|
return fetchAPI('/v1/admin/billing/stats/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminInvoices(params?: { status?: string; account_id?: number; search?: string }): Promise<{
|
export async function getAdminInvoices(params?: { status?: string; account_id?: number; search?: string }): Promise<{
|
||||||
@@ -332,7 +336,8 @@ export async function getCreditCosts(): Promise<{
|
|||||||
results: CreditCostConfig[];
|
results: CreditCostConfig[];
|
||||||
count: number;
|
count: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/admin/billing/credit-costs/');
|
// credit costs are served from the admin namespace (modules billing)
|
||||||
|
return fetchAPI('/v1/admin/credit-costs/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCreditCosts(
|
export async function updateCreditCosts(
|
||||||
@@ -344,7 +349,7 @@ export async function updateCreditCosts(
|
|||||||
message: string;
|
message: string;
|
||||||
updated_count: number;
|
updated_count: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/admin/billing/credit-costs/', {
|
return fetchAPI('/v1/admin/credit-costs/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ costs }),
|
body: JSON.stringify({ costs }),
|
||||||
});
|
});
|
||||||
@@ -434,6 +439,15 @@ export async function getCreditPackages(): Promise<{
|
|||||||
return fetchAPI('/v1/billing/credit-packages/');
|
return fetchAPI('/v1/billing/credit-packages/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin credit packages (CRUD capable backend; currently read-only here)
|
||||||
|
export async function getAdminCreditPackages(): Promise<{
|
||||||
|
results: CreditPackage[];
|
||||||
|
count: number;
|
||||||
|
}> {
|
||||||
|
// Backend does not expose a dedicated admin credit-packages list; fall back to the shared packages list
|
||||||
|
return fetchAPI('/v1/billing/credit-packages/');
|
||||||
|
}
|
||||||
|
|
||||||
export async function purchaseCreditPackage(data: {
|
export async function purchaseCreditPackage(data: {
|
||||||
package_id: number;
|
package_id: number;
|
||||||
payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet';
|
payment_method: 'stripe' | 'paypal' | 'bank_transfer' | 'local_wallet';
|
||||||
@@ -447,7 +461,7 @@ export async function purchaseCreditPackage(data: {
|
|||||||
stripe_client_secret?: string;
|
stripe_client_secret?: string;
|
||||||
paypal_order_id?: string;
|
paypal_order_id?: string;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI(`/v1/billing/credit-packages/${data.package_id}/purchase/`, {
|
return fetchAPI(`/api/v1/billing/credit-packages/${data.package_id}/purchase/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ payment_method: data.payment_method }),
|
body: JSON.stringify({ payment_method: data.payment_method }),
|
||||||
});
|
});
|
||||||
@@ -495,7 +509,7 @@ export async function getAvailablePaymentMethods(): Promise<{
|
|||||||
results: PaymentMethod[];
|
results: PaymentMethod[];
|
||||||
count: number;
|
count: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/billing/payment-methods/');
|
return fetchAPI('/api/v1/billing/payment-methods/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createManualPayment(data: {
|
export async function createManualPayment(data: {
|
||||||
@@ -509,7 +523,7 @@ export async function createManualPayment(data: {
|
|||||||
status: string;
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/billing/payments/manual/', {
|
return fetchAPI('/api/v1/billing/payments/manual/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -523,7 +537,7 @@ export async function getPendingPayments(): Promise<{
|
|||||||
results: PendingPayment[];
|
results: PendingPayment[];
|
||||||
count: number;
|
count: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI('/v1/billing/admin/pending_payments/');
|
return fetchAPI('/api/v1/billing/admin/pending_payments/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approvePayment(paymentId: number, data?: {
|
export async function approvePayment(paymentId: number, data?: {
|
||||||
@@ -532,7 +546,7 @@ export async function approvePayment(paymentId: number, data?: {
|
|||||||
message: string;
|
message: string;
|
||||||
payment: Payment;
|
payment: Payment;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI(`/v1/billing/admin/${paymentId}/approve_payment/`, {
|
return fetchAPI(`/api/v1/billing/admin/${paymentId}/approve_payment/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data || {}),
|
body: JSON.stringify(data || {}),
|
||||||
});
|
});
|
||||||
@@ -545,7 +559,7 @@ export async function rejectPayment(paymentId: number, data: {
|
|||||||
message: string;
|
message: string;
|
||||||
payment: Payment;
|
payment: Payment;
|
||||||
}> {
|
}> {
|
||||||
return fetchAPI(`/v1/billing/admin/${paymentId}/reject_payment/`, {
|
return fetchAPI(`/api/v1/billing/admin/${paymentId}/reject_payment/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user