Add prepaid resource credits with prorated deploy billing.

When users delete an app before plan expiry, remaining resources become credits for a new deploy. The deploy calculator shows covered vs additional charges, prices optional services correctly, and prorates extras to days left on the credit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 17:37:44 +03:30
parent 35dd771f63
commit 5239e8aa94
17 changed files with 1021 additions and 96 deletions
+14 -4
View File
@@ -566,9 +566,14 @@ export default function AppDetailPage() {
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/applications/${appId}`),
onSuccess: () => {
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
toast.success('Application deleted');
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
if (res.data?.resourceCredit) {
toast.success('Application deleted. Prepaid resources are on your dashboard.');
} else {
toast.success('Application deleted');
}
router.push('/dashboard/apps');
},
onError: () => toast.error('Failed to delete application'),
@@ -862,7 +867,11 @@ export default function AppDetailPage() {
const handleDelete = async () => {
const ok = await confirm({
title: `Delete "${app.name}"?`,
message: 'This will permanently remove:\n• All Kubernetes resources (pods, services, ingress)\n• Database and volumes\n• All deployment records\n• Uploaded source code',
message:
'This will permanently remove all Kubernetes resources, data, and deployment records.\n\n' +
(app.planExpiresAt && new Date(app.planExpiresAt) > new Date()
? 'Your remaining paid resources will appear on the dashboard for use on a new app at no extra charge.'
: ''),
confirmText: 'Delete',
variant: 'danger',
});
@@ -937,11 +946,12 @@ export default function AppDetailPage() {
</>
)}
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
</button>
</div>
</div>
{/* Renewal Banner for Expired/Suspended Apps */}
{needsRenewal && (
<div className={`rounded-xl p-4 border-2 ${
+28 -19
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, AppLifecycleStatus } from '@/types';
import type { Application } from '@/types';
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
@@ -57,9 +57,14 @@ export default function AppsPage() {
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/applications/${id}`),
onSuccess: () => {
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
toast.success('Application deleted');
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
if (res.data?.resourceCredit) {
toast.success('Application deleted. Your prepaid resources are shown on the dashboard.');
} else {
toast.success('Application deleted');
}
},
onError: () => toast.error('Failed to delete application'),
});
@@ -110,7 +115,6 @@ export default function AppsPage() {
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block table-wrapper">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50/80">
@@ -129,22 +133,21 @@ export default function AppsPage() {
const lifecycle = app.lifecycleStatus || 'active';
const expiry = formatExpiry(app.planExpiresAt);
return (
<tr key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}>
<tr
key={app.id}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}
>
<td className="px-6 py-4">
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
</div>
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
{app.name}
</span>
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
<td className="px-6 py-4">
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
{latestStatus}
</span>
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
@@ -168,8 +171,14 @@ export default function AppsPage() {
View
</Link>
<button
type="button"
onClick={async () => {
const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
const ok = await confirm({
title: 'Delete Application',
message: `Permanently delete "${app.name}" and all its data?\n\nIf your plan still has time left, the prepaid resources will appear on your dashboard for use on a new app at no extra charge.`,
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteMutation.mutate(app.id);
}}
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
@@ -185,7 +194,6 @@ export default function AppsPage() {
</table>
</div>
{/* Mobile Cards */}
<div className="md:hidden grid gap-3">
{apps.map((app) => {
const latestStatus = app.deployments?.[0]?.status || 'pending';
@@ -207,11 +215,8 @@ export default function AppsPage() {
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
</div>
</div>
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
{latestStatus}
</span>
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
</div>
{/* Lifecycle & Expiry row */}
<div className="flex items-center gap-3 mb-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
@@ -225,8 +230,12 @@ export default function AppsPage() {
)}
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
<span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
<span className="flex items-center gap-1">
<Database className="w-3 h-3" /> {app.databaseType}
</span>
<span className="flex items-center gap-1">
<Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}
</span>
</div>
</Link>
);
+135 -36
View File
@@ -7,7 +7,7 @@ import api from '@/lib/api';
import { parseDotenv } from '@/lib/parseDotenv';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, DeployCostPreview, BillingCycle } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react';
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
@@ -123,22 +123,25 @@ export default function DeployPage() {
},
});
// Cost calculation for the review step
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain],
queryFn: () => api.post('/billing/calculate', {
runtime: form.runtime,
databaseType: form.databaseType,
cpuLimit: form.cpuLimit,
memoryLimit: form.memoryLimit,
replicas: form.replicas,
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
enableRedis: form.enableRedis,
enableRabbitmq: form.enableRabbitmq,
enableElasticsearch: form.enableElasticsearch,
enableCustomDomain,
}).then((r) => r.data),
const deployCostPayload = {
runtime: form.runtime,
databaseType: form.databaseType,
cpuLimit: form.cpuLimit,
memoryLimit: form.memoryLimit,
replicas: form.replicas,
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined,
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`,
enableRedis: form.enableRedis,
enableRabbitmq: form.enableRabbitmq,
enableElasticsearch: form.enableElasticsearch,
enableCustomDomain,
cycle: selectedCycle,
};
// Cost calculation for the review step (includes prepaid resource credits)
const { data: costData, isLoading: costLoading } = useQuery<DeployCostPreview>({
queryKey: ['deploy-cost', deployCostPayload],
queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data),
enabled: step === 3,
});
@@ -149,9 +152,14 @@ export default function DeployPage() {
enabled: step === 3,
});
const payAmount = costData ? costData[selectedCycle] : 0;
const payAmount = costData?.amountDue ?? 0;
const fullPrice = costData?.fullAmount ?? 0;
const coveredAmount = costData?.coveredAmount ?? 0;
const extrasBreakdown = costData?.extrasBreakdown ?? [];
const prepaidCreditUsed = costData?.prepaidCreditUsed ?? false;
const walletBalance = walletData?.balance ?? 0;
const hasEnoughBalance = walletBalance >= payAmount;
const hasEnoughBalance = payAmount === 0 || walletBalance >= payAmount;
const requiresPayment = (costData?.monthly ?? 0) > 0 && payAmount > 0;
const walletPayMutation = useMutation({
mutationFn: async () => {
@@ -218,18 +226,20 @@ export default function DeployPage() {
mutationFn: async () => {
// Initiate gateway
setDeployStage('paying');
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Deploy: ${form.name} (${selectedCycle})`,
callbackUrl: `${window.location.origin}/dashboard/deploy`,
});
if (payAmount > 0) {
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Deploy: ${form.name} (${selectedCycle})`,
callbackUrl: `${window.location.origin}/dashboard/deploy`,
});
// In production, redirect to gw.gatewayUrl
// For now, auto-verify (simulated)
await api.post('/billing/gateway/verify', {
trackingCode: gw.trackingCode,
amount: payAmount,
});
// In production, redirect to gw.gatewayUrl
// For now, auto-verify (simulated)
await api.post('/billing/gateway/verify', {
trackingCode: gw.trackingCode,
amount: payAmount,
});
}
// Now create the app
setDeployStage('creating');
@@ -2227,6 +2237,18 @@ export default function DeployPage() {
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
) : costData && costData.monthly > 0 ? (
<div className="space-y-3">
{prepaidCreditUsed && costData.creditApplied && (
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900">
<p className="font-medium">Prepaid credit applied</p>
<p className="text-indigo-700 mt-0.5">
Resources from &quot;{costData.creditApplied.sourceAppName || 'deleted app'}&quot; are covered
until {costData.creditApplied.remainingLabel} remaining.
{payAmount > 0
? ` You only pay for new add-ons below (prorated to ${costData.prorateRemainingDays ?? '?'}/${costData.proratePeriodDays ?? '?'} days left on your credit).`
: ' No charge for this deploy.'}
</p>
</div>
)}
{/* Billing cycle selector */}
<div className="grid grid-cols-3 gap-2">
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
@@ -2244,14 +2266,67 @@ export default function DeployPage() {
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
</p>
<p className="text-lg font-bold text-emerald-700">
{Number(costData[cycle]).toLocaleString('en-US')}
{prepaidCreditUsed && cycle === selectedCycle && fullPrice > payAmount ? (
<>
<span className="block text-xs font-normal text-gray-400 line-through">
{Number(costData[cycle]).toLocaleString('en-US')}
</span>
{Number(payAmount).toLocaleString('en-US')}
</>
) : (
Number(costData[cycle]).toLocaleString('en-US')
)}
</p>
<p className="text-xs text-gray-400">Toman</p>
</button>
))}
</div>
{costData.breakdown && costData.breakdown.length > 0 && (
{prepaidCreditUsed && (
<div className="mt-2 pt-3 border-t border-emerald-200/50 space-y-2">
<p className="text-xs font-medium text-gray-500">Prepaid credit pricing</p>
<div className="flex justify-between text-xs py-1">
<span className="text-gray-600">Full plan price ({selectedCycle})</span>
<span className="text-gray-900 font-medium">
{Number(fullPrice).toLocaleString('en-US')} T
</span>
</div>
<div className="flex justify-between text-xs py-1 text-indigo-700">
<span>Covered by prepaid credit</span>
<span className="font-medium">
{Number(coveredAmount).toLocaleString('en-US')} T
</span>
</div>
{extrasBreakdown.length > 0 ? (
<>
<p className="text-xs font-medium text-amber-700 pt-1">Additional charges (you pay)</p>
{extrasBreakdown.map((item, i) => (
<div key={i} className="flex justify-between text-xs py-1 gap-2">
<span className="text-gray-600">{item.label}</span>
<span className="text-amber-800 font-medium text-right shrink-0">
{item.fullPeriodAmount != null && item.fullPeriodAmount > item.amount && (
<span className="text-gray-400 line-through block text-[10px]">
{Number(item.fullPeriodAmount).toLocaleString('en-US')} T full period
</span>
)}
+{Number(item.amount).toLocaleString('en-US')} T
</span>
</div>
))}
</>
) : (
<p className="text-xs text-emerald-700">No additional charges fully covered.</p>
)}
<div className="flex justify-between text-sm py-2 border-t border-emerald-200/50 font-semibold">
<span className="text-gray-800">Amount due ({selectedCycle})</span>
<span className="text-emerald-800">
{Number(payAmount).toLocaleString('en-US')} Toman
</span>
</div>
</div>
)}
{!prepaidCreditUsed && costData.breakdown && costData.breakdown.length > 0 && (
<div className="mt-2 pt-3 border-t border-emerald-200/50">
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</p>
{costData.breakdown.map((item, i) => (
@@ -2262,6 +2337,12 @@ export default function DeployPage() {
</span>
</div>
))}
<div className="flex justify-between text-sm py-2 border-t border-emerald-200/50 mt-1 font-semibold">
<span className="text-gray-800">Amount due ({selectedCycle})</span>
<span className="text-emerald-800">
{Number(payAmount).toLocaleString('en-US')} Toman
</span>
</div>
</div>
)}
</div>
@@ -2271,7 +2352,12 @@ export default function DeployPage() {
</div>
{/* Payment Method */}
{costData && costData.monthly > 0 && (
{costData && costData.monthly > 0 && !requiresPayment && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
No payment required your prepaid resource credit covers this deployment.
</div>
)}
{requiresPayment && (
<div className="bg-white rounded-xl p-5 border border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Payment Method</h3>
<div className="grid grid-cols-2 gap-3">
@@ -2286,7 +2372,9 @@ export default function DeployPage() {
<p className="mt-2 font-semibold text-sm text-gray-900">Pay from Wallet</p>
<p className="text-xs text-gray-500 mt-1">
Balance: {Number(walletBalance).toLocaleString('en-US')} T
{!hasEnoughBalance && <span className="text-red-500 block mt-0.5">Insufficient balance</span>}
{requiresPayment && !hasEnoughBalance && (
<span className="text-red-500 block mt-0.5">Insufficient balance</span>
)}
</p>
</button>
<button
@@ -2304,7 +2392,14 @@ export default function DeployPage() {
<div className="mt-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
<span className="text-sm text-gray-600">Amount to pay ({selectedCycle})</span>
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
<div className="text-right">
{prepaidCreditUsed && fullPrice > payAmount && (
<span className="block text-sm text-gray-400 line-through">
{Number(fullPrice).toLocaleString('en-US')} Toman
</span>
)}
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
</div>
</div>
</div>
)}
@@ -2337,6 +2432,8 @@ export default function DeployPage() {
if (!costData || costData.monthly === 0) {
// No pricing — deploy directly
handleSubmit();
} else if (payAmount === 0) {
walletPayMutation.mutate();
} else if (paymentMethod === 'wallet') {
if (!hasEnoughBalance) {
toast.error('Insufficient wallet balance. Please top up or use payment gateway.');
@@ -2355,7 +2452,9 @@ export default function DeployPage() {
? `Uploading... ${uploadProgress}%`
: 'Processing...'
: costData && costData.monthly > 0
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
? payAmount === 0
? <><Rocket className="w-4 h-4 inline" /> Deploy with prepaid credit</>
: <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
</button>
)}
+48 -1
View File
@@ -6,7 +6,8 @@ import type { ReactNode } from 'react';
import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import type { Application } from '@/types';
import { Rocket, Package, Circle, Hexagon } from 'lucide-react';
import { Rocket, Package, Circle, Hexagon, Wallet, Clock } from 'lucide-react';
import type { ResourceCredit } from '@/types';
const statusColors: Record<string, string> = {
running: 'bg-emerald-100 text-emerald-700',
@@ -36,6 +37,11 @@ export default function DashboardPage() {
queryFn: () => api.get('/applications').then((r) => r.data),
});
const { data: resourceCredits = [] } = useQuery<ResourceCredit[]>({
queryKey: ['resource-credits'],
queryFn: () => api.get('/billing/resource-credits').then((r) => r.data),
});
const runningApps = apps.filter(
(a) => a.deployments?.some((d) => d.status === 'running'),
);
@@ -55,6 +61,47 @@ export default function DashboardPage() {
</p>
</div>
{resourceCredits.length > 0 && (
<div className="card border-2 border-indigo-100 bg-indigo-50/40 space-y-4">
<div className="flex items-start gap-3">
<Wallet className="w-5 h-5 text-indigo-600 shrink-0 mt-0.5" />
<div>
<h2 className="font-semibold text-indigo-900">Prepaid resource credits</h2>
<p className="text-sm text-indigo-700 mt-1">
If you delete an app before your plan ends, you can deploy a new app with the same resources at no extra charge until the credit expires.
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{resourceCredits.map((credit) => (
<div key={credit.id} className="rounded-xl border border-indigo-200 bg-white p-4 text-sm">
<p className="font-semibold text-gray-900">
{credit.sourceAppName ? `From app “${credit.sourceAppName}` : 'Resource credit'}
</p>
<p className="mt-2 inline-flex items-center gap-1 text-indigo-700 font-medium">
<Clock className="w-3.5 h-3.5" />
{credit.remainingLabel} remaining
</p>
<ul className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-gray-600">
<li>CPU: {credit.cpuLimit}</li>
<li>RAM: {credit.memoryLimit}</li>
<li>Replicas: {credit.replicas}</li>
<li>DB: {credit.databaseType}</li>
<li>DB disk: {credit.dbStorageSize}</li>
<li>App disk: {credit.appStorageSize}</li>
{credit.enableRedis && <li>Redis</li>}
{credit.enableRabbitmq && <li>RabbitMQ</li>}
{credit.enableElasticsearch && <li>Elasticsearch</li>}
</ul>
<Link href="/dashboard/deploy" className="mt-3 inline-block text-xs font-medium text-primary-600 hover:underline">
Use on new app
</Link>
</div>
))}
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="stat-card">