feat: add Solana USDT/USDC support and refine admin payouts UI
This commit is contained in:
23
app/api/admin/payouts/list/route.ts
Normal file
23
app/api/admin/payouts/list/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
169
app/api/admin/payouts/route.ts
Normal file
169
app/api/admin/payouts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
72
app/api/admin/treasury/balances/route.ts
Normal file
72
app/api/admin/treasury/balances/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
34
app/api/merchants/[id]/balances/route.ts
Normal file
34
app/api/merchants/[id]/balances/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
Reference in New Issue
Block a user