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:
keyhan
2026-04-07 02:05:58 +03:30
parent 4974f88e8c
commit c2c6a32ae8
9 changed files with 441 additions and 138 deletions
+92 -58
View File
@@ -5,12 +5,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { WalletTransaction, TransactionType } from '@/types';
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock } from 'lucide-react';
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard } from 'lucide-react';
const txTypeLabels: Record<TransactionType, string> = {
charge: 'شارژ',
deduction: 'کسر',
refund: 'بازگشت',
charge: 'Deposit',
deduction: 'Payment',
refund: 'Refund',
};
const txTypeColors: Record<TransactionType, string> = {
@@ -40,48 +40,81 @@ export default function WalletPage() {
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
});
// Direct wallet charge (simulated — in production this would go through payment gateway)
const chargeMutation = useMutation({
mutationFn: (amount: number) =>
api.post('/billing/wallet/charge', { amount, description: 'شارژ کیف پول' }),
api.post('/billing/wallet/charge', { amount, description: 'Wallet top-up' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
toast.success('کیف پول شارژ شد');
toast.success('Wallet charged successfully');
setChargeAmount('');
setShowCharge(false);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در شارژ'),
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to charge wallet'),
});
const handleCharge = () => {
// Payment gateway charge
const gatewayMutation = useMutation({
mutationFn: async (amount: number) => {
const { data } = await api.post('/billing/gateway/initiate', {
amount,
description: 'Wallet top-up via gateway',
callbackUrl: `${window.location.origin}/dashboard/wallet`,
});
return data;
},
onSuccess: async (data) => {
// In production, redirect to data.gatewayUrl
// For now, auto-verify (simulated)
await api.post('/billing/gateway/verify', {
trackingCode: data.trackingCode,
amount: Number(chargeAmount),
});
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
toast.success('Payment successful — wallet charged');
setChargeAmount('');
setShowCharge(false);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'),
});
const handleCharge = (method: 'wallet' | 'gateway') => {
const amount = Number(chargeAmount);
if (!amount || amount < 1000) {
toast.error('حداقل مبلغ شارژ ۱,۰۰۰ تومان');
toast.error('Minimum charge amount is 1,000 Toman');
return;
}
chargeMutation.mutate(amount);
if (method === 'wallet') {
chargeMutation.mutate(amount);
} else {
gatewayMutation.mutate(amount);
}
};
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
const formatDate = (d: string) => new Date(d).toLocaleDateString('fa-IR', {
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
const formatDate = (d: string) => new Date(d).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
const isPending = chargeMutation.isPending || gatewayMutation.isPending;
return (
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
<div>
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> کیف پول</h1>
<p className="page-subtitle">مدیریت موجودی و تراکنشها</p>
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> Wallet</h1>
<p className="page-subtitle">Manage your balance and transactions</p>
</div>
{/* Balance Card */}
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">موجودی فعلی</p>
<p className="text-sm opacity-80">Current Balance</p>
<p className="text-3xl font-bold mt-1">
{walletLoading ? '...' : `${formatPrice(walletData?.balance ?? 0)}`}
<span className="text-lg font-normal mr-2">تومان</span>
{walletLoading ? '...' : formatPrice(walletData?.balance ?? 0)}
<span className="text-lg font-normal ml-2">Toman</span>
</p>
</div>
{!showCharge && (
@@ -89,49 +122,50 @@ export default function WalletPage() {
onClick={() => setShowCharge(true)}
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
>
<Plus className="w-4 h-4" /> شارژ کیف پول
<Plus className="w-4 h-4" /> Top Up
</button>
)}
</div>
{showCharge && (
<div className="mt-4 pt-4 border-t border-white/20 flex items-center gap-3">
<input
type="number"
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
placeholder="مبلغ (تومان) — حداقل ۱,۰۰۰"
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
min={1000}
/>
<button
onClick={handleCharge}
disabled={chargeMutation.isPending}
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50"
>
{chargeMutation.isPending ? '...' : 'پرداخت'}
</button>
<button
onClick={() => setShowCharge(false)}
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
>
انصراف
</button>
</div>
)}
{/* Quick charge amounts */}
{showCharge && (
<div className="flex gap-2 mt-3">
{[10000, 50000, 100000, 500000].map((amt) => (
<div className="mt-4 pt-4 border-t border-white/20">
<div className="flex items-center gap-3">
<input
type="number"
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
placeholder="Amount (Toman) — min 1,000"
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
min={1000}
/>
<button
key={amt}
onClick={() => setChargeAmount(String(amt))}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
onClick={() => handleCharge('gateway')}
disabled={isPending}
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{amt.toLocaleString('fa-IR')} ت
<CreditCard className="w-4 h-4" />
{gatewayMutation.isPending ? '...' : 'Pay Now'}
</button>
))}
<button
onClick={() => setShowCharge(false)}
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
>
Cancel
</button>
</div>
{/* Quick charge amounts */}
<div className="flex gap-2 mt-3">
{[10000, 50000, 100000, 500000].map((amt) => (
<button
key={amt}
onClick={() => setChargeAmount(String(amt))}
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
>
{amt.toLocaleString('en-US')}
</button>
))}
</div>
</div>
)}
</div>
@@ -139,13 +173,13 @@ export default function WalletPage() {
{/* Transactions */}
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-gray-400" /> تاریخچه تراکنشها
<Clock className="w-5 h-5 text-gray-400" /> Transaction History
</h2>
{txLoading ? (
<div className="text-center py-8 text-gray-400">در حال بارگذاری...</div>
<div className="text-center py-8 text-gray-400">Loading...</div>
) : transactions.length === 0 ? (
<div className="text-center py-8 text-gray-400">هنوز تراکنشی ثبت نشده</div>
<div className="text-center py-8 text-gray-400">No transactions yet</div>
) : (
<div className="divide-y divide-gray-100">
{transactions.map((tx) => (
@@ -162,11 +196,11 @@ export default function WalletPage() {
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
</div>
</div>
<div className="text-left">
<div className="text-right">
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
{tx.type === 'deduction' ? '' : '+'}{formatPrice(tx.amount)} ت
{tx.type === 'deduction' ? '' : '+'}{formatPrice(tx.amount)} T
</p>
<p className="text-xs text-gray-400">مانده: {formatPrice(tx.balanceAfter)} ت</p>
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
</div>
</div>
))}