Add invoice payment management.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
|
||||
@@ -259,6 +259,20 @@ export default function AppDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const createRenewalInvoiceMutation = useMutation({
|
||||
mutationFn: (cycle: string) =>
|
||||
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
|
||||
onSuccess: (invoice) => {
|
||||
toast.success('Invoice created. Choose how you want to pay.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowRenewalModal(false);
|
||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to create renewal invoice');
|
||||
},
|
||||
});
|
||||
|
||||
// Check if app needs renewal (expired or suspended)
|
||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
||||
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
@@ -631,6 +645,19 @@ export default function AppDetailPage() {
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'),
|
||||
});
|
||||
|
||||
const createUpgradeInvoiceMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
|
||||
onSuccess: (invoice) => {
|
||||
toast.success('Invoice created. Choose how you want to pay.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
|
||||
});
|
||||
|
||||
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
|
||||
const handleScaleResources = () => {
|
||||
if (scaleWorkload !== 'app') {
|
||||
@@ -1110,11 +1137,10 @@ export default function AppDetailPage() {
|
||||
: renewalCostData.costs.yearly;
|
||||
if (walletData.balance < cost) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
Wallet is short by {(cost - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1132,18 +1158,14 @@ export default function AppDetailPage() {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => renewMutation.mutate(selectedCycle)}
|
||||
disabled={renewMutation.isPending || !renewalCostData?.costs || (walletData && renewalCostData?.costs && (
|
||||
(selectedCycle === 'hourly' && walletData.balance < renewalCostData.costs.hourly) ||
|
||||
(selectedCycle === 'monthly' && walletData.balance < renewalCostData.costs.monthly) ||
|
||||
(selectedCycle === 'yearly' && walletData.balance < renewalCostData.costs.yearly)
|
||||
))}
|
||||
onClick={() => createRenewalInvoiceMutation.mutate(selectedCycle)}
|
||||
disabled={createRenewalInvoiceMutation.isPending || !renewalCostData?.costs}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{renewMutation.isPending ? (
|
||||
{createRenewalInvoiceMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Processing...</>
|
||||
) : (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Renew</>
|
||||
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1203,11 +1225,10 @@ export default function AppDetailPage() {
|
||||
|
||||
{/* Insufficient Balance Warning */}
|
||||
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
Wallet is short by {(upgradeCostData.proratedAmount - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1224,14 +1245,20 @@ export default function AppDetailPage() {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scaleMutation.mutate(resourceForm)}
|
||||
disabled={scaleMutation.isPending || (walletData && upgradeCostData.proratedAmount > walletData.balance)}
|
||||
onClick={() => {
|
||||
if (upgradeCostData.proratedAmount > 0) {
|
||||
createUpgradeInvoiceMutation.mutate(resourceForm);
|
||||
} else {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
}
|
||||
}}
|
||||
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
|
||||
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{scaleMutation.isPending ? (
|
||||
{scaleMutation.isPending || createUpgradeInvoiceMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Applying...</>
|
||||
) : upgradeCostData.proratedAmount > 0 ? (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Upgrade</>
|
||||
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4" /> Apply Changes</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user