feat: add Solana USDT/USDC support and refine admin payouts UI

This commit is contained in:
mstfyldz
2026-03-13 05:17:04 +03:00
parent 5f0df83686
commit 641498957c
16 changed files with 1335 additions and 120 deletions

View File

@@ -16,7 +16,8 @@ import {
ChevronDown,
Wallet,
Building2,
Code2
Code2,
ArrowUpRight
} from 'lucide-react';
export default function AdminLayout({
@@ -35,6 +36,7 @@ export default function AdminLayout({
const navItems = [
{ label: 'Genel Bakış', icon: LayoutDashboard, href: '/admin' },
{ label: 'Firmalar', icon: Building2, href: '/admin/merchants' },
{ label: 'Ödemeler', icon: ArrowUpRight, href: '/admin/payouts' },
{ label: 'İşlemler', icon: CreditCard, href: '/admin/transactions' },
{ label: 'Müşteriler', icon: Users, href: '/admin/customers' },
{ label: 'Analizler', icon: BarChart3, href: '/admin/analytics' },

View File

@@ -3,30 +3,54 @@
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import {
Plus,
Building2,
Copy,
ExternalLink,
MoreVertical,
Globe,
Check,
Pencil,
Trash2,
X
Plus, Search, Building2, Copy, Check, Pencil, Trash2,
ExternalLink, Globe, LayoutGrid, List, AlertCircle, X,
DollarSign, Wallet, ArrowRight, ShieldCheck, Loader2,
Coins, History
} from 'lucide-react';
export default function MerchantsPage() {
const [merchants, setMerchants] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [treasuryData, setTreasuryData] = useState<any>(null);
const [isLoadingTreasury, setIsLoadingTreasury] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingMerchant, setEditingMerchant] = useState<any>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [showPayoutModal, setShowPayoutModal] = useState(false);
const [selectedMerchant, setSelectedMerchant] = useState<any>(null);
const [payoutAmount, setPayoutAmount] = useState<string>('');
const [payoutNetwork, setPayoutNetwork] = useState<string>('POLYGON');
const [payoutCurrency, setPayoutCurrency] = useState<string>('USDT');
const [merchantBalances, setMerchantBalances] = useState<any[]>([]);
const [merchantFeePercent, setMerchantFeePercent] = useState<number>(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editForm, setEditForm] = useState({
name: '',
webhook_url: '',
fee_percent: '',
payout_address: '',
payout_addresses: { EVM: '', SOLANA: '', TRON: '', BITCOIN: '' } as Record<string, string>
});
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
fetchMerchants();
fetchTreasury();
}, []);
const fetchTreasury = async () => {
setIsLoadingTreasury(true);
try {
const res = await fetch('/api/admin/treasury/balances');
const data = await res.json();
if (res.ok) setTreasuryData(data.balances);
} catch (e) {
console.error(e);
} finally {
setIsLoadingTreasury(false);
}
};
const fetchMerchants = async () => {
setIsLoading(true);
try {
@@ -48,30 +72,52 @@ export default function MerchantsPage() {
};
const handleEditClick = (merchant: any) => {
setEditingMerchant({ ...merchant });
setIsEditModalOpen(true);
setSelectedMerchant(merchant);
setEditForm({
name: merchant.name,
webhook_url: merchant.webhook_url || '',
fee_percent: merchant.fee_percent?.toString() || '1.0',
payout_address: merchant.payout_address || '',
payout_addresses: merchant.payout_addresses || { EVM: '', SOLANA: '', TRON: '', BITCOIN: '' }
});
setShowEditModal(true);
};
const handlePayoutClick = async (merchant: any) => {
setSelectedMerchant(merchant);
setPayoutAmount('');
setShowPayoutModal(true);
// Fetch merchant's per-network balances
try {
const res = await fetch(`/api/merchants/${merchant.id}/balances`);
const data = await res.json();
setMerchantBalances(data.balances || []);
setMerchantFeePercent(data.feePercent || 1);
} catch (e) {
setMerchantBalances([]);
}
};
const handleUpdateMerchant = async (e: React.FormEvent) => {
e.preventDefault();
setIsUpdating(true);
try {
const response = await fetch(`/api/merchants/${editingMerchant.id}`, {
const response = await fetch(`/api/merchants/${selectedMerchant.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editingMerchant.name,
webhook_url: editingMerchant.webhook_url,
payment_provider: editingMerchant.payment_provider,
provider_config: editingMerchant.provider_config,
fee_percent: editingMerchant.fee_percent
name: editForm.name,
webhook_url: editForm.webhook_url,
fee_percent: parseFloat(editForm.fee_percent),
payout_address: editForm.payout_address,
payout_addresses: editForm.payout_addresses
})
});
if (!response.ok) throw new Error('Güncelleme başarısız.');
await fetchMerchants();
setIsEditModalOpen(false);
setEditingMerchant(null);
setShowEditModal(false);
setSelectedMerchant(null);
} catch (err: any) {
alert(err.message);
} finally {
@@ -93,6 +139,38 @@ export default function MerchantsPage() {
}
};
const handleProcessPayout = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedMerchant || parseFloat(payoutAmount) <= 0) return;
setIsSubmitting(true);
try {
const response = await fetch('/api/admin/payouts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
merchantId: selectedMerchant.id,
amount: parseFloat(payoutAmount),
network: payoutNetwork,
currency: payoutCurrency
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Ödeme işlemi başarısız.');
alert('Ödeme başarıyla gönderildi! TX Hash: ' + data.txHash);
await fetchMerchants();
setShowPayoutModal(false);
setSelectedMerchant(null);
setPayoutAmount('');
} catch (err: any) {
alert(err.message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header */}
@@ -116,6 +194,7 @@ export default function MerchantsPage() {
</Link>
</div>
{/* Merchant Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{isLoading ? (
@@ -164,19 +243,42 @@ export default function MerchantsPage() {
</div>
{/* Balance Section */}
<div className="mb-6 p-4 bg-emerald-50/50 rounded-2xl border border-emerald-100 flex items-center justify-between">
<div>
<p className="text-[10px] font-black text-emerald-600 uppercase tracking-widest pl-1">İçerideki Bakiye</p>
<p className="text-xl font-black text-emerald-700">
{new Intl.NumberFormat('tr-TR', { style: 'currency', currency: 'TRY' }).format(m.available_balance || 0)}
</p>
</div>
<div className="text-right">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Toplam Ödenen</p>
<p className="text-xs font-bold text-gray-600">
{new Intl.NumberFormat('tr-TR', { style: 'currency', currency: 'TRY' }).format(m.withdrawn_balance || 0)}
</p>
<div className="mb-6 p-4 bg-emerald-50/50 rounded-2xl border border-emerald-100 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-black text-emerald-600 uppercase tracking-widest pl-1">İçerideki Bakiye</p>
<p className="text-xl font-black text-emerald-700">
{new Intl.NumberFormat('tr-TR', { style: 'currency', currency: 'TRY' }).format(Number(m.available_balance || 0))}
</p>
</div>
<div className="text-right">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Toplam Ödenen</p>
<p className="text-xs font-bold text-gray-600">
{new Intl.NumberFormat('tr-TR', { style: 'currency', currency: 'TRY' }).format(Number(m.withdrawn_balance || 0))}
</p>
</div>
</div>
{m.balance_breakdown && m.balance_breakdown.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2 border-t border-emerald-100/50">
{m.balance_breakdown.map((b: any, i: number) => (
<div key={i} className="px-2 py-1 bg-white/60 rounded-lg border border-emerald-100/50 flex items-center gap-1.5 shadow-sm">
<div className={`w-1.5 h-1.5 rounded-full ${b.network === 'SOLANA' ? 'bg-emerald-400' : b.network === 'POLYGON' ? 'bg-purple-400' : b.network === 'TRON' ? 'bg-red-400' : 'bg-orange-400'}`}></div>
<span className="text-[9px] font-black text-emerald-800 uppercase tabular-nums">
{Number(b.amount).toFixed(4)} {b.token}
</span>
</div>
))}
</div>
)}
<button
onClick={() => handlePayoutClick(m)}
disabled={parseFloat(m.available_balance || '0') <= 0}
className="w-full flex items-center justify-center gap-2 py-3 bg-emerald-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-emerald-700 disabled:opacity-50 disabled:grayscale transition-all shadow-lg shadow-emerald-100"
>
<Wallet size={14} /> Ödeme Gönder (Withdraw)
</button>
</div>
<div className="space-y-4">
@@ -188,7 +290,7 @@ export default function MerchantsPage() {
{m.api_key || '••••••••••••••••'}
</div>
<button
onClick={() => copyToClipboard(m.api_key, m.id + '-key')}
onClick={() => copyToClipboard(m.api_key || '', m.id + '-key')}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
>
{copiedId === m.id + '-key' ? <Check size={14} /> : <Copy size={14} />}
@@ -199,7 +301,7 @@ export default function MerchantsPage() {
{/* Webhook Section */}
<div className="p-4 bg-gray-50 rounded-2xl space-y-2 border border-transparent hover:border-gray-100 transition-colors">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-1">Webhook URL</label>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-gray-600 truncate px-1">{m.webhook_url || 'Ayarlanmamış'}</span>
<Globe size={14} className="text-gray-300 shrink-0" />
</div>
@@ -268,7 +370,7 @@ export default function MerchantsPage() {
</div>
{/* Edit Modal */}
{isEditModalOpen && (
{showEditModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-6 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white w-full max-w-lg rounded-[40px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-10">
@@ -278,7 +380,7 @@ export default function MerchantsPage() {
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-2 px-1">Firma bilgilerini güncelle</p>
</div>
<button
onClick={() => setIsEditModalOpen(false)}
onClick={() => setShowEditModal(false)}
className="p-3 hover:bg-gray-50 rounded-2xl text-gray-400 transition"
>
<X size={24} />
@@ -294,38 +396,14 @@ export default function MerchantsPage() {
<input
type="text"
required
value={editingMerchant?.name || ''}
onChange={(e) => setEditingMerchant({ ...editingMerchant, name: e.target.value })}
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder="Örn: Ayris Teknoloji"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-[24px] outline-none transition-all font-bold text-gray-900 placeholder:text-gray-300"
/>
</div>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Ödeme Altyapısı (Gateway)</label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'stripe', name: 'Stripe' },
{ id: 'cryptomus', name: 'Cryptomus' },
{ id: 'nuvei', name: 'Nuvei' },
{ id: 'paykings', name: 'PayKings' },
{ id: 'securionpay', name: 'SecurionPay' },
].map((p) => (
<button
key={p.id}
type="button"
onClick={() => setEditingMerchant({ ...editingMerchant, payment_provider: p.id })}
className={`px-4 py-3 rounded-xl border text-xs font-bold transition-all ${editingMerchant?.payment_provider === p.id
? 'border-blue-500 bg-blue-50 text-blue-600'
: 'border-gray-100 bg-gray-50 text-gray-400 hover:bg-gray-100'}`}
>
{p.name}
</button>
))}
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Özel Komisyon (%)</label>
<div className="relative">
@@ -335,35 +413,60 @@ export default function MerchantsPage() {
step="0.1"
min="0"
max="100"
value={editingMerchant?.fee_percent || '1.0'}
onChange={(e) => setEditingMerchant({ ...editingMerchant, fee_percent: e.target.value })}
value={editForm.fee_percent}
onChange={(e) => setEditForm({ ...editForm, fee_percent: e.target.value })}
placeholder="1.0"
className="w-full pl-6 pr-12 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-[24px] outline-none transition-all font-bold text-gray-900 placeholder:text-gray-300"
/>
</div>
<p className="text-[10px] text-gray-400 font-medium px-1">Bu firmaya özel kesinti oranı. Boş bırakılırsa sistem varsayılanı kullanılır.</p>
<p className="text-[10px] text-gray-400 font-medium px-1">Kesinti oranı. Boş bırakılırsa sistem varsayılanı kullanılır.</p>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Webhook URL (Geri Dönüş)</label>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Webhook URL</label>
<div className="relative">
<Globe className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="url"
value={editingMerchant?.webhook_url || ''}
onChange={(e) => setEditingMerchant({ ...editingMerchant, webhook_url: e.target.value })}
value={editForm.webhook_url}
onChange={(e) => setEditForm({ ...editForm, webhook_url: e.target.value })}
placeholder="https://siteniz.com/webhook"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-[24px] outline-none transition-all font-bold text-gray-900 placeholder:text-gray-300"
/>
</div>
<p className="text-[10px] text-gray-400 font-medium px-1">Ödeme başarılı olduğunda bu adrese bildirim gönderilecektir.</p>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Ödeme Adresleri ( Bazlı)</label>
<p className="text-[9px] text-gray-400 italic px-1 -mt-1">Her için payout yapılacak cüzdan adresini girin.</p>
{[
{ key: 'EVM', label: 'EVM (Polygon/BSC/ETH)', placeholder: '0x...', icon: '🟣' },
{ key: 'SOLANA', label: 'Solana', placeholder: '5pLH...veya base58 adres', icon: '🟢' },
{ key: 'TRON', label: 'TRON', placeholder: 'T...', icon: '🔴' },
{ key: 'BITCOIN', label: 'Bitcoin', placeholder: 'bc1q...', icon: '🟠' }
].map(net => (
<div key={net.key} className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-base">{net.icon}</span>
<input
type="text"
value={editForm.payout_addresses?.[net.key] || ''}
onChange={(e) => setEditForm({
...editForm,
payout_addresses: { ...editForm.payout_addresses, [net.key]: e.target.value }
})}
className="w-full pl-12 pr-6 py-3 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-2xl outline-none transition-all font-mono text-xs text-gray-900 placeholder:text-gray-300"
placeholder={`${net.label}: ${net.placeholder}`}
/>
</div>
))}
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setIsEditModalOpen(false)}
onClick={() => setShowEditModal(false)}
className="flex-1 py-4 text-gray-400 font-black text-sm uppercase tracking-widest hover:text-gray-600 transition"
>
İptal
@@ -381,6 +484,182 @@ export default function MerchantsPage() {
</div>
</div>
)}
{/* Payout Modal */}
{showPayoutModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-[40px] w-full max-w-lg max-h-[90vh] overflow-y-auto shadow-2xl animate-in zoom-in duration-300">
<div className="bg-emerald-600 p-8 text-white relative">
<button onClick={() => setShowPayoutModal(false)} className="absolute top-6 right-6 p-2 hover:bg-white/10 rounded-full transition">
<X size={24} />
</button>
<div className="flex items-center gap-4 mb-4">
<div className="w-16 min-w-[64px] h-16 bg-white/10 rounded-3xl flex items-center justify-center">
<Wallet size={32} />
</div>
<div>
<h3 className="text-2xl font-black">Ödeme Gönder</h3>
<p className="text-xs text-white/60 font-bold uppercase tracking-widest">Treasury'den Merchant Cüzdanına Transfer</p>
</div>
</div>
</div>
<form onSubmit={handleProcessPayout} className="p-8 space-y-6">
{(() => {
const bal = merchantBalances.find(b => b.network === payoutNetwork && b.token === payoutCurrency);
const totalGross = bal ? bal.totalGross : 0;
const totalNet = bal ? bal.balance : 0;
const totalFee = totalGross - totalNet;
const withdrawn = bal ? bal.withdrawn : 0;
const available = totalNet - withdrawn;
return (
<div className="bg-emerald-50 p-6 rounded-3xl border border-emerald-100 space-y-4">
<div className="flex justify-between items-center">
<p className="text-[10px] font-black text-emerald-600 uppercase tracking-widest">Alıcı Firma</p>
<p className="text-sm font-bold text-emerald-700">{selectedMerchant?.name}</p>
</div>
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-emerald-200/30">
<div>
<p className="text-[9px] font-black text-emerald-600/60 uppercase tracking-tight">Top. Brüt (Hazine)</p>
<p className="text-xs font-black text-emerald-800 tabular-nums">{totalGross.toFixed(4)} {payoutCurrency}</p>
</div>
<div className="text-right">
<p className="text-[9px] font-black text-emerald-600/60 uppercase tracking-tight">Platform Fee (%{merchantFeePercent})</p>
<p className="text-xs font-black text-orange-600 tabular-nums">-{totalFee.toFixed(4)} {payoutCurrency}</p>
</div>
</div>
<div className="pt-2 border-t border-emerald-200/50">
<p className="text-[10px] font-black text-emerald-600 uppercase tracking-widest mb-1">Net Hak Ediş (Çekilebilir)</p>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-black text-emerald-900 tabular-nums">{available.toFixed(6)}</p>
<p className="text-sm font-black text-emerald-700">{payoutCurrency}</p>
</div>
{withdrawn > 0 && (
<p className="text-[9px] text-emerald-600/60 font-bold mt-1">Daha önce {withdrawn.toFixed(4)} {payoutCurrency} çekildi.</p>
)}
</div>
{merchantBalances.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-emerald-200/30">
{merchantBalances.filter(b => b.available > 0).map((b, i) => (
<span key={i} className={`px-2 py-0.5 rounded-lg text-[8px] font-bold uppercase tabular-nums border cursor-pointer transition-all ${b.network === payoutNetwork && b.token === payoutCurrency ? 'bg-emerald-600 text-white border-emerald-600 shadow-md' : 'bg-white text-emerald-700 border-emerald-100 hover:border-emerald-300'}`}
onClick={() => { setPayoutNetwork(b.network); setPayoutCurrency(b.token); }}
>
{b.network}/{b.token}: {b.available.toFixed(4)}
</span>
))}
</div>
)}
</div>
);
})()}
<div className="space-y-4">
{/* Simplified Selection Indicator */}
<div className="p-4 bg-gray-50 rounded-2xl border border-gray-100 mb-4">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest leading-none mb-2">Seçili Ödeme Hattı</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center text-lg shadow-sm border border-gray-100">
{payoutNetwork === 'SOLANA' ? '🟢' : payoutNetwork === 'TRON' ? '🔴' : payoutNetwork === 'BITCOIN' ? '🟠' : '🟣'}
</div>
<div>
<p className="text-sm font-black text-gray-900">{payoutNetwork}</p>
<p className="text-[10px] font-bold text-blue-600 uppercase tracking-tight">{payoutCurrency}</p>
</div>
</div>
<span className="text-[9px] font-black text-gray-300 uppercase italic">Yukarıdaki chiplerden seçin</span>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 pl-1">
Gönderilecek {payoutCurrency} Miktarı
</label>
{(() => {
const bal = merchantBalances.find(b => b.network === payoutNetwork && b.token === payoutCurrency);
const maxAvailable = bal ? bal.available : 0;
const isOverspend = parseFloat(payoutAmount || '0') > maxAvailable;
return (
<>
<div className="relative">
<input
type="number"
step="0.00000001"
value={payoutAmount}
onChange={(e) => setPayoutAmount(e.target.value)}
className={`w-full pl-5 pr-24 py-4 bg-gray-50 border rounded-2xl focus:ring-2 focus:ring-emerald-500 transition outline-none font-black text-lg ${isOverspend ? 'border-red-400 bg-red-50' : 'border-gray-100'}`}
placeholder="0.00"
required
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{maxAvailable > 0 && (
<button
type="button"
onClick={() => setPayoutAmount(maxAvailable.toString())}
className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-[9px] font-black uppercase hover:bg-emerald-200 transition"
>
MAX
</button>
)}
<span className="font-black text-gray-300 uppercase text-sm">{payoutCurrency}</span>
</div>
</div>
{isOverspend && (
<p className="text-[9px] text-red-500 font-black mt-1 px-1">⚠️ Girilen miktar firma bakiyesini ({maxAvailable.toFixed(6)} {payoutCurrency}) aşıyor!</p>
)}
</>
);
})()}
<p className="text-[9px] text-gray-400 mt-1 italic px-1">Treasury cüzdanından düşülüp merchant adresine kripto olarak iletilecek.</p>
</div>
<div className="p-4 bg-blue-50 rounded-2xl border border-blue-100 flex gap-3">
<ShieldCheck className="text-blue-600 shrink-0" size={20} />
<p className="text-[10px] text-blue-700 font-bold leading-relaxed">
Bu işlem kalıcıdır. Payout adresi ({payoutNetwork}):
<span className="font-mono text-[10px] block mt-1 break-all">
{(() => {
const netKey = ['POLYGON', 'BSC', 'ETH'].includes(payoutNetwork) ? 'EVM' : payoutNetwork;
const addr = selectedMerchant?.payout_addresses?.[netKey];
return addr || <span className="text-red-500 font-black">ADRES EKSİK! Lütfen firma ayarlarından {netKey} adresini girin.</span>;
})()}
</span>
</p>
</div>
</div>
<button
type="submit"
disabled={isSubmitting || (() => {
const bal = merchantBalances.find(b => b.network === payoutNetwork && b.token === payoutCurrency);
const maxAvailable = bal ? bal.available : 0;
return parseFloat(payoutAmount || '0') > maxAvailable || parseFloat(payoutAmount || '0') <= 0;
})() || !(() => {
const netKey = ['POLYGON', 'BSC', 'ETH'].includes(payoutNetwork) ? 'EVM' : payoutNetwork;
return selectedMerchant?.payout_addresses?.[netKey];
})()}
className="w-full py-5 bg-gray-900 text-white rounded-2xl text-xs font-black uppercase tracking-[0.2em] hover:bg-black transition shadow-xl disabled:opacity-50 flex items-center justify-center gap-3"
>
{isSubmitting ? (
<>
<Loader2 className="animate-spin" size={18} />
İşleniyor...
</>
) : (
<>
Ödemeyi Onayla & Gönder
<ArrowRight size={18} />
</>
)}
</button>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { tr } from 'date-fns/locale';
import Link from 'next/link';
import TransactionChart from '@/components/admin/TransactionChart';
import QueryRangeSelector from '@/components/admin/QueryRangeSelector';
import PlatformTreasuryWidget from '@/components/admin/PlatformTreasuryWidget';
async function getStats(rangeDays: number = 30) {
const result = await db.query('SELECT * FROM transactions ORDER BY created_at DESC');
@@ -143,6 +144,9 @@ export default async function AdminDashboard(props: {
</div>
</div>
{/* Platform Treasury Status - High Visibility */}
<PlatformTreasuryWidget />
{/* Middle Section: Charts */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm">
<div className="flex justify-between items-center mb-10">

172
app/admin/payouts/page.tsx Normal file
View File

@@ -0,0 +1,172 @@
import React from 'react';
import { db } from '@/lib/db';
import {
Search,
ExternalLink,
Clock,
Wallet,
Link as LinkIcon,
ChevronRight,
ArrowUpRight
} from 'lucide-react';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
async function getPayouts() {
try {
const { rows } = await db.query(`
SELECT p.*, m.name as merchant_name
FROM payouts p
LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY p.created_at DESC
`);
return rows;
} catch (error) {
console.error('Payouts fetch error:', error);
return [];
}
}
export default async function PayoutsPage() {
const payouts = await getPayouts();
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header Area */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Ödemeler (Payouts)</h1>
<p className="text-gray-500 font-medium mt-1 uppercase tracking-widest text-[10px]">Merchantlara yapılan kripto ödeme geçmişi</p>
</div>
</div>
{/* Stats Overview (Optional) */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-[32px] border border-gray-100 shadow-sm flex items-center gap-5">
<div className="w-12 h-12 bg-emerald-50 rounded-2xl flex items-center justify-center text-emerald-600">
<ArrowUpRight size={24} />
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Toplam Ödeme</p>
<p className="text-xl font-black text-gray-900">{payouts.length}</p>
</div>
</div>
<div className="bg-white p-6 rounded-[32px] border border-gray-100 shadow-sm flex items-center gap-5">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600">
<Wallet size={24} />
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Son İşlem</p>
<p className="text-xl font-black text-gray-900">
{payouts.length > 0 ? format(new Date(payouts[0].created_at), 'dd MMM', { locale: tr }) : '-'}
</p>
</div>
</div>
<div className="bg-white p-6 rounded-[32px] border border-gray-100 shadow-sm flex items-center gap-5">
<div className="w-12 h-12 bg-orange-50 rounded-2xl flex items-center justify-center text-orange-600">
<Clock size={24} />
</div>
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Durum</p>
<p className="text-xl font-black text-gray-900">Aktif</p>
</div>
</div>
</div>
{/* Payouts Table */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden text-sans tracking-tight">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/30 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
<th className="px-10 py-6">Merchant</th>
<th className="px-10 py-6">Miktar & Varlık</th>
<th className="px-10 py-6"> (Network)</th>
<th className="px-10 py-6">Hedef Adres</th>
<th className="px-10 py-6">Tarih</th>
<th className="px-10 py-6 text-center">Durum</th>
<th className="px-10 py-6 text-right">Blockchain</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{payouts.map((p: any) => (
<tr key={p.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="px-10 py-8">
<div className="flex flex-col">
<span className="text-xs font-black text-blue-600 uppercase tracking-wider">
{p.merchant_name}
</span>
<span className="text-[9px] text-gray-400 font-bold mt-1">ID: {p.merchant_id.slice(0,8)}...</span>
</div>
</td>
<td className="px-10 py-8 font-black text-gray-900">
<div className="flex items-center gap-2">
<span className="text-lg">{Number(p.amount).toFixed(4)}</span>
<span className="text-xs text-gray-400 uppercase">{p.currency}</span>
</div>
</td>
<td className="px-10 py-8">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-wider ${
p.network === 'SOLANA' ? 'bg-purple-50 text-purple-600' :
p.network === 'TRON' ? 'bg-red-50 text-red-600' :
'bg-blue-50 text-blue-600'
}`}>
{p.network}
</span>
</div>
</td>
<td className="px-10 py-8">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-xs font-mono text-gray-500 bg-gray-50 px-3 py-1.5 rounded-xl w-fit">
<Wallet size={12} className="text-gray-400" />
{p.destination_address.slice(0, 8)}...{p.destination_address.slice(-8)}
</div>
</div>
</td>
<td className="px-10 py-8">
<span className="text-xs font-bold text-gray-500">
{format(new Date(p.created_at), 'dd MMM yyyy, HH:mm', { locale: tr })}
</span>
</td>
<td className="px-10 py-8">
<div className="flex justify-center">
<span className={`inline-flex items-center px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${p.status === 'succeeded' ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
{p.status === 'succeeded' ? 'Başarılı' : 'Hatalı'}
</span>
</div>
</td>
<td className="px-10 py-8 text-right">
{p.tx_hash && p.tx_hash !== 'mock' ? (
<a
href={p.network === 'SOLANA' ? `https://explorer.solana.com/tx/${p.tx_hash}` :
p.network === 'TRON' ? `https://tronscan.org/#/transaction/${p.tx_hash}` :
`https://polygonscan.com/tx/${p.tx_hash}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 font-black text-[10px] uppercase tracking-widest transition-all p-2 bg-blue-50 rounded-xl"
>
TX GÖRÜNTÜLE
<ExternalLink size={14} />
</a>
) : (
<span className="text-[10px] font-black text-gray-300 uppercase tracking-widest italic">Mock İşlem</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{payouts.length === 0 && (
<div className="p-20 text-center space-y-4">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto text-gray-300">
<Wallet size={32} />
</div>
<p className="text-gray-400 font-bold uppercase tracking-widest text-xs">Henüz ödeme yapılmadı</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(req: NextRequest) {
try {
const result = await db.query(`
SELECT
p.*,
m.name as merchant_name
FROM payouts p
LEFT JOIN merchants m ON p.merchant_id = m.id
ORDER BY p.created_at DESC
`);
return NextResponse.json({
success: true,
payouts: result.rows
});
} catch (error: any) {
console.error('[Payouts List API] Error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { CryptoEngine } from '@/lib/crypto-engine';
export async function POST(req: NextRequest) {
try {
const { merchantId, amount, network, currency } = await req.json();
if (!merchantId || !amount || !network) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// 1. Fetch Merchant details
const merchantRes = await db.query('SELECT * FROM merchants WHERE id = $1', [merchantId]);
const merchant = merchantRes.rows[0];
if (!merchant) {
return NextResponse.json({ error: 'Merchant not found' }, { status: 404 });
}
// Resolve per-network payout address
const netKey = ['POLYGON', 'BSC', 'ETH'].includes(network) ? 'EVM' : network;
const payoutAddresses = merchant.payout_addresses || {};
const destinationAddress = payoutAddresses[netKey] || merchant.payout_address;
if (!destinationAddress) {
return NextResponse.json({ error: `Merchant payout address for ${netKey} is not set. Please configure it in merchant settings.` }, { status: 400 });
}
const availableBalance = parseFloat(merchant.available_balance || '0');
const payoutAmount = parseFloat(amount);
if (availableBalance < payoutAmount) {
return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 });
}
// 2. Get Treasury Private Key based on network
const treasuryKeys: Record<string, string | undefined> = {
'POLYGON': process.env.TREASURY_EVM_KEY,
'BSC': process.env.TREASURY_EVM_KEY,
'ETH': process.env.TREASURY_EVM_KEY,
'SOLANA': process.env.TREASURY_SOL_KEY,
'TRON': process.env.TREASURY_TRON_KEY,
'BITCOIN': process.env.TREASURY_BTC_KEY
};
const privateKey = treasuryKeys[network];
if (!privateKey) {
return NextResponse.json({ error: `Treasury private key for ${network} is not configured in .env` }, { status: 500 });
}
// 3. Check merchant's crypto balance for this network/token
const selectedCurrency = currency || 'SOL';
const balRes = await db.query(
'SELECT balance, withdrawn FROM merchant_balances WHERE merchant_id = $1 AND network = $2 AND token = $3',
[merchantId, network, selectedCurrency]
);
const cryptoBalance = balRes.rows[0] ? parseFloat(balRes.rows[0].balance) : 0;
const cryptoWithdrawn = balRes.rows[0] ? parseFloat(balRes.rows[0].withdrawn) : 0;
const cryptoAvailable = cryptoBalance - cryptoWithdrawn;
if (cryptoAvailable < payoutAmount) {
return NextResponse.json({
error: `Yetersiz ${selectedCurrency} bakiyesi. Çekilebilir: ${cryptoAvailable.toFixed(6)} ${selectedCurrency}, talep edilen: ${payoutAmount} ${selectedCurrency}`
}, { status: 400 });
}
// 4. Execute Transfer
const engine = new CryptoEngine(network);
const transfer = await engine.sendPayout(
privateKey,
destinationAddress,
amount.toString(),
selectedCurrency
);
if (!transfer.success) {
return NextResponse.json({ error: transfer.error || 'Blockchain transfer failed' }, { status: 500 });
}
// 5. Update Database
await db.query('BEGIN');
try {
// Deduct from merchant's crypto balance
await db.query(`
UPDATE merchant_balances
SET withdrawn = withdrawn + $1
WHERE merchant_id = $2 AND network = $3 AND token = $4
`, [payoutAmount, merchantId, network, selectedCurrency]);
// Also update legacy TRY balance (best effort price conversion)
try {
const coinIdMap: Record<string, string> = {
'SOL': 'solana', 'USDC': 'usd-coin', 'USDT': 'tether',
'TRX': 'tron', 'BTC': 'bitcoin', 'ETH': 'ethereum',
'MATIC': 'matic-network', 'BNB': 'binancecoin'
};
const coinId = coinIdMap[selectedCurrency] || 'solana';
const priceRes = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=try`);
const priceData = await priceRes.json();
const tryEquivalent = payoutAmount * (priceData[coinId]?.try || 0);
if (tryEquivalent > 0) {
await db.query(`
UPDATE merchants
SET available_balance = GREATEST(0, available_balance - $1),
withdrawn_balance = withdrawn_balance + $1
WHERE id = $2
`, [tryEquivalent, merchantId]);
}
} catch (e) {
console.warn('[Payout] TRY balance update skipped (price fetch failed)');
}
// Log the payout
await db.query(`
INSERT INTO payouts (merchant_id, amount, currency, network, destination_address, tx_hash, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [
merchantId,
payoutAmount,
selectedCurrency,
network,
destinationAddress,
transfer.txHash,
'succeeded'
]);
await db.query('COMMIT');
console.log(`[Payout] ✅ Sent ${payoutAmount} ${selectedCurrency} on ${network} to ${destinationAddress}`);
} catch (dbErr) {
await db.query('ROLLBACK');
throw dbErr;
}
return NextResponse.json({
success: true,
txHash: transfer.txHash,
message: 'Payout processed successfully'
});
} catch (err: any) {
console.error('Payout Error:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const merchantId = searchParams.get('merchantId');
let query = 'SELECT * FROM payouts ORDER BY created_at DESC';
let params: any[] = [];
if (merchantId) {
query = 'SELECT * FROM payouts WHERE merchant_id = $1 ORDER BY created_at DESC';
params = [merchantId];
}
const result = await db.query(query, params);
return NextResponse.json(result.rows);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { CryptoEngine } from '@/lib/crypto-engine';
import { ethers } from 'ethers';
import { PublicKey } from '@solana/web3.js';
export async function GET() {
try {
// 1. Fetch platform addresses from settings
const settingsRes = await db.query("SELECT key, value FROM system_settings WHERE key IN ('sol_platform_address', 'evm_platform_address', 'tron_platform_address', 'btc_platform_address')");
const settings: Record<string, string> = {};
settingsRes.rows.forEach(r => settings[r.key] = r.value);
const addresses = {
EVM: settings.evm_platform_address || "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
SOLANA: settings.sol_platform_address || "Ajr4nKieZJVu9q2d1eVF9pQPRCLoZ6v4tapB3iQn2SyQ",
TRON: settings.tron_platform_address || "TY795B6FmDNV4Xm5U6G1rP9yvX7S9rK6G1P", // Valid TRON address format
BITCOIN: settings.btc_platform_address || "17V95B6FmDNV4Xm5U6G1rP9yvX7S9rK6G1P" // Valid BTC address format
};
// 2. Fetch balances for each network from config
const cryptoConfig = require('@/lib/crypto-config.json');
const balances: any = {};
for (const netConfig of cryptoConfig.networks) {
const net = netConfig.id;
console.log(`[Treasury] Fetching balances for ${net}...`);
const engine = new CryptoEngine(net);
const addr = (addresses as any)[['POLYGON', 'BSC', 'ETH'].includes(net) ? 'EVM' : net];
if (!addr) {
console.warn(`[Treasury] No address found for network ${net}`);
continue;
}
try {
const tokenBalances: Record<string, string> = {};
let nativeBalance = "0.00";
let nativeSymbol = net;
for (const token of netConfig.tokens) {
const balance = await engine.getBalance(addr, token.symbol);
if (token.address === 'NATIVE') {
nativeBalance = balance;
nativeSymbol = token.symbol;
} else {
tokenBalances[token.symbol] = balance;
}
}
balances[net] = {
address: addr,
native: nativeBalance,
nativeSymbol: nativeSymbol,
tokens: tokenBalances
};
} catch (err) {
console.error(`[Treasury Balance Error] ${net}:`, err);
balances[net] = { address: addr, native: "Error", tokens: {} };
}
}
return NextResponse.json({
success: true,
balances
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -59,7 +59,7 @@ export async function POST(request: Request) {
const map: Record<string, string> = {};
result.rows.forEach(r => map[r.key] = r.value);
return {
sol: map.sol_platform_address || process.env.SOL_PLATFORM_ADDRESS || "5pLH1tqZhx8p8WpZ18yr28N42KXB3FXVPzZ9ceCtpBVe",
sol: map.sol_platform_address || process.env.SOL_PLATFORM_ADDRESS || "Ajr4nKieZJVu9q2d1eVF9pQPRCLoZ6v4tapB3iQn2SyQ",
evm: map.evm_platform_address || process.env.EVM_PLATFORM_ADDRESS || "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
tron: map.tron_platform_address || process.env.TRON_PLATFORM_ADDRESS || "TLYpfG6rre8Gv9m8pYjR7yvX7S9rK6G1P",
btc: map.btc_platform_address || process.env.BTC_PLATFORM_ADDRESS || "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfJH",
@@ -139,12 +139,35 @@ export async function POST(request: Request) {
const feeAmount = (grossAmount * feePercent) / 100;
const merchantNetCredit = grossAmount - feeAmount;
// 6.2 Update Merchant's virtual balance
// 6.2 Calculate crypto credit after fee
const cryptoAmount = parseFloat(expectedCryptoAmount);
const cryptoFee = (cryptoAmount * feePercent) / 100;
const cryptoNetCredit = cryptoAmount - cryptoFee;
// 6.3 Update Merchant's TRY balance (legacy)
await db.query(`UPDATE merchants SET available_balance = available_balance + $1 WHERE id = $2`,
[merchantNetCredit, transaction.merchant_id]);
// 6.3 Update transaction status
await db.query(`UPDATE transactions SET status = 'succeeded' WHERE id = $1`, [transaction.id]);
// 6.4 Update Merchant's per-network crypto balance
await db.query(`
INSERT INTO merchant_balances (merchant_id, network, token, balance, total_gross)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (merchant_id, network, token)
DO UPDATE SET
balance = merchant_balances.balance + $4,
total_gross = merchant_balances.total_gross + $5
`, [transaction.merchant_id, selectedNetwork, selectedToken, cryptoNetCredit, cryptoAmount]);
// 6.5 Update transaction status and recorded blockchain info
await db.query(`
UPDATE transactions
SET status = 'succeeded',
paid_network = $2,
paid_token = $3,
paid_amount_crypto = $4
WHERE id = $1`,
[transaction.id, selectedNetwork, selectedToken, expectedCryptoAmount]
);
// 7. Automated Webhook Notification
if (transaction.callback_url) {

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const result = await db.query(
'SELECT network, token, balance, withdrawn, total_gross FROM merchant_balances WHERE merchant_id = $1 ORDER BY network, token',
[id]
);
// Also get the merchant fee
const merchantRes = await db.query('SELECT fee_percent FROM merchants WHERE id = $1', [id]);
const feePercent = parseFloat(merchantRes.rows[0]?.fee_percent || '1.0');
return NextResponse.json({
balances: result.rows.map(r => ({
network: r.network,
token: r.token,
balance: parseFloat(r.balance),
withdrawn: parseFloat(r.withdrawn),
totalGross: parseFloat(r.total_gross || r.balance),
available: parseFloat(r.balance) - parseFloat(r.withdrawn)
})),
feePercent
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -29,19 +29,48 @@ export async function PATCH(
) {
try {
const { id } = await context.params;
const { name, webhook_url, payment_provider, provider_config, fee_percent } = await req.json();
const body = await req.json();
if (!name) {
if (!body.name) {
return NextResponse.json(
{ error: 'Firma adı zorunludur.' },
{ status: 400 }
);
}
const result = await db.query(
'UPDATE merchants SET name = $1, webhook_url = $2, payment_provider = $3, provider_config = $4, fee_percent = $5 WHERE id = $6 RETURNING *',
[name, webhook_url, payment_provider, provider_config, fee_percent || 1.0, id]
);
// Build dynamic update
const fields: string[] = [];
const values: any[] = [];
let idx = 1;
const addField = (col: string, val: any) => {
if (val !== undefined) {
fields.push(`${col} = $${idx++}`);
values.push(val);
}
};
addField('name', body.name);
addField('webhook_url', body.webhook_url);
addField('fee_percent', body.fee_percent || 1.0);
addField('payout_address', body.payout_address);
if (body.payout_addresses !== undefined) {
fields.push(`payout_addresses = $${idx++}`);
values.push(JSON.stringify(body.payout_addresses));
}
if (body.payment_provider !== undefined) {
addField('payment_provider', body.payment_provider);
}
if (body.provider_config !== undefined) {
fields.push(`provider_config = $${idx++}`);
values.push(JSON.stringify(body.provider_config));
}
values.push(id);
const query = `UPDATE merchants SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`;
const result = await db.query(query, values);
const data = result.rows[0];
if (!data) {
@@ -50,6 +79,7 @@ export async function PATCH(
return NextResponse.json(data);
} catch (err: any) {
console.error('[Merchant PATCH Error]', err);
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }

View File

@@ -48,8 +48,37 @@ export async function POST(req: NextRequest) {
export async function GET() {
try {
const result = await db.query('SELECT * FROM merchants ORDER BY created_at DESC');
return NextResponse.json(result.rows);
const merchantsResult = await db.query('SELECT * FROM merchants ORDER BY created_at DESC');
const merchants = merchantsResult.rows;
// Fetch breakdown per merchant
const breakdownResult = await db.query(`
SELECT
merchant_id,
COALESCE(paid_network, 'SİSTEM') as network,
COALESCE(paid_token, 'TRY') as token,
SUM(COALESCE(paid_amount_crypto, amount)) as amount
FROM transactions
WHERE status = 'succeeded'
GROUP BY merchant_id, paid_network, paid_token
`);
const breakdowns = breakdownResult.rows.reduce((acc: any, row: any) => {
if (!acc[row.merchant_id]) acc[row.merchant_id] = [];
acc[row.merchant_id].push({
network: row.network,
token: row.token,
amount: row.amount
});
return acc;
}, {});
const merchantsWithBreakdown = merchants.map(m => ({
...m,
balance_breakdown: breakdowns[m.id] || []
}));
return NextResponse.json(merchantsWithBreakdown);
} catch (err: any) {
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },