feat: billing v2 — runtime-based plans, English UI, payment flow
- ServicePlan now has 'runtime' field (nodejs/laravel/wordpress) - Admin billing page: Application Type dropdown, English UI, Toman prices - calculateCost filters active plans by matching runtime - Wallet page: English UI, payment gateway integration (Pay Now button) - Deploy page Review step: billing cycle selector (hourly/monthly/yearly), payment method choice (wallet or payment gateway), Pay & Deploy button - Payment gateway endpoints: POST /billing/gateway/initiate + /verify (simulated — ready for Zarinpal/IDPay integration) - Deploy requires payment: wallet deduction or gateway charge before deploy
This commit is contained in:
@@ -6,8 +6,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown } 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 } from 'lucide-react';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, 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 } from 'lucide-react';
|
||||
|
||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||
|
||||
@@ -48,6 +48,9 @@ export default function DeployPage() {
|
||||
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
|
||||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dbUploadProgress, setDbUploadProgress] = useState(0);
|
||||
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
|
||||
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
|
||||
queryKey: ['clusters-public'],
|
||||
@@ -75,6 +78,123 @@ export default function DeployPage() {
|
||||
enabled: step === 3,
|
||||
});
|
||||
|
||||
// Wallet balance for the review step payment
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
});
|
||||
|
||||
const payAmount = costData ? costData[selectedCycle] : 0;
|
||||
const walletBalance = walletData?.balance ?? 0;
|
||||
const hasEnoughBalance = walletBalance >= payAmount;
|
||||
|
||||
const walletPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// First create the app
|
||||
const payload = { ...form };
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
const res = await api.post('/applications', payload);
|
||||
const appId = res.data.id;
|
||||
|
||||
// Upload source
|
||||
if (sourceMethod === 'upload' && zipFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', zipFile);
|
||||
await api.post(`/applications/${appId}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Upload DB dump
|
||||
if (form.databaseType !== 'none' && dbDumpFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', dbDumpFile);
|
||||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Deduct from wallet
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||||
|
||||
return res;
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Payment successful! Deploying...');
|
||||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||||
router.push(`/dashboard/apps/${res.data.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Payment or deployment failed');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
});
|
||||
|
||||
const gatewayPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Initiate gateway
|
||||
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,
|
||||
});
|
||||
|
||||
// Now create the app
|
||||
const payload = { ...form };
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
const res = await api.post('/applications', payload);
|
||||
const appId = res.data.id;
|
||||
|
||||
// Upload source
|
||||
if (sourceMethod === 'upload' && zipFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', zipFile);
|
||||
await api.post(`/applications/${appId}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Upload DB dump
|
||||
if (form.databaseType !== 'none' && dbDumpFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', dbDumpFile);
|
||||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||||
});
|
||||
}
|
||||
|
||||
// Deduct from the wallet (which was just charged by gateway)
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||||
|
||||
return res;
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Payment successful! Deploying...');
|
||||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||||
router.push(`/dashboard/apps/${res.data.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Payment failed');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: CreateApplicationDto) => {
|
||||
const res = await api.post('/applications', data);
|
||||
@@ -1116,45 +1236,93 @@ export default function DeployPage() {
|
||||
{/* Cost Breakdown */}
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl p-5 sm:p-6 border border-emerald-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-3">
|
||||
<DollarSign className="w-4 h-4 text-emerald-600" /> برآورد هزینه
|
||||
<DollarSign className="w-4 h-4 text-emerald-600" /> Cost Estimate
|
||||
</h3>
|
||||
{costLoading ? (
|
||||
<div className="text-sm text-gray-400 text-center py-3">در حال محاسبه...</div>
|
||||
) : costData ? (
|
||||
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
|
||||
) : costData && costData.monthly > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="bg-white rounded-lg p-3 shadow-sm">
|
||||
<p className="text-xs text-gray-500">ساعتی</p>
|
||||
<p className="text-lg font-bold text-emerald-700">{Number(costData.hourly).toLocaleString('fa-IR')}</p>
|
||||
<p className="text-xs text-gray-400">تومان</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 shadow-sm ring-2 ring-emerald-200">
|
||||
<p className="text-xs text-gray-500">ماهانه</p>
|
||||
<p className="text-lg font-bold text-emerald-700">{Number(costData.monthly).toLocaleString('fa-IR')}</p>
|
||||
<p className="text-xs text-gray-400">تومان</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 shadow-sm">
|
||||
<p className="text-xs text-gray-500">سالانه</p>
|
||||
<p className="text-lg font-bold text-emerald-700">{Number(costData.yearly).toLocaleString('fa-IR')}</p>
|
||||
<p className="text-xs text-gray-400">تومان</p>
|
||||
</div>
|
||||
{/* Billing cycle selector */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
|
||||
<button
|
||||
key={cycle}
|
||||
type="button"
|
||||
onClick={() => setSelectedCycle(cycle)}
|
||||
className={`rounded-lg p-3 text-center transition-all ${
|
||||
selectedCycle === cycle
|
||||
? 'bg-white ring-2 ring-emerald-400 shadow-md'
|
||||
: 'bg-white/60 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-emerald-700">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Toman</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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">جزئیات</p>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</p>
|
||||
{costData.breakdown.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-xs py-1">
|
||||
<span className="text-gray-600">{item.label}</span>
|
||||
<span className="text-gray-900 font-medium">{Number(item.monthly).toLocaleString('fa-IR')} ت/ماه</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{Number(item[selectedCycle]).toLocaleString('en-US')} T/{selectedCycle === 'hourly' ? 'hr' : selectedCycle === 'monthly' ? 'mo' : 'yr'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400 text-center py-3">هنوز پلنی تعریف نشده</div>
|
||||
<div className="text-sm text-gray-400 text-center py-3">No pricing plans defined yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{costData && costData.monthly > 0 && (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('wallet')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||||
paymentMethod === 'wallet' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Wallet className="w-5 h-5 text-primary-600" />
|
||||
<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>}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('gateway')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||||
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5 text-emerald-600" />
|
||||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay Now</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Online payment gateway</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1177,15 +1345,30 @@ export default function DeployPage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!costData || costData.monthly === 0) {
|
||||
// No pricing — deploy directly
|
||||
handleSubmit();
|
||||
} else if (paymentMethod === 'wallet') {
|
||||
if (!hasEnoughBalance) {
|
||||
toast.error('Insufficient wallet balance. Please top up or use payment gateway.');
|
||||
return;
|
||||
}
|
||||
walletPayMutation.mutate();
|
||||
} else {
|
||||
gatewayPayMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending
|
||||
{(createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending)
|
||||
? uploadProgress > 0 && uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
: 'Deploying...'
|
||||
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
|
||||
: 'Processing...'
|
||||
: costData && costData.monthly > 0
|
||||
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||||
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user