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

@@ -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}` },