Add invoice payment management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-18 21:34:47 +03:30
parent 880521c576
commit 8b197e69bc
15 changed files with 1616 additions and 58 deletions
+48 -21
View File
@@ -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</>
)}