feat: implement background payment sync worker and admin trigger UI
This commit is contained in:
@@ -11,6 +11,7 @@ import { format } from 'date-fns';
|
|||||||
import { tr } from 'date-fns/locale';
|
import { tr } from 'date-fns/locale';
|
||||||
import TransactionSearch from '@/components/admin/TransactionSearch';
|
import TransactionSearch from '@/components/admin/TransactionSearch';
|
||||||
import TransactionStatusFilter from '@/components/admin/TransactionStatusFilter';
|
import TransactionStatusFilter from '@/components/admin/TransactionStatusFilter';
|
||||||
|
import SyncPaymentsButton from '@/components/admin/SyncPaymentsButton';
|
||||||
|
|
||||||
async function getTransactions(filters: { merchant_id?: string; q?: string; status?: string }) {
|
async function getTransactions(filters: { merchant_id?: string; q?: string; status?: string }) {
|
||||||
let sql = `
|
let sql = `
|
||||||
@@ -71,6 +72,8 @@ export default async function TransactionsPage(props: {
|
|||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<TransactionSearch />
|
<TransactionSearch />
|
||||||
<TransactionStatusFilter />
|
<TransactionStatusFilter />
|
||||||
|
<div className="h-8 w-px bg-gray-100 mx-2"></div>
|
||||||
|
<SyncPaymentsButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="flex items-center justify-center gap-2 px-6 py-3 bg-gray-900 text-white rounded-2xl text-sm font-bold hover:bg-gray-800 transition shadow-lg shadow-gray-200">
|
<button className="flex items-center justify-center gap-2 px-6 py-3 bg-gray-900 text-white rounded-2xl text-sm font-bold hover:bg-gray-800 transition shadow-lg shadow-gray-200">
|
||||||
|
|||||||
22
app/api/admin/sync-payments/route.ts
Normal file
22
app/api/admin/sync-payments/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { syncPendingPayments } from '@/lib/sync-worker';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate admin (simple check for now, can be hardened)
|
||||||
|
// In a real app, check session or API key
|
||||||
|
|
||||||
|
const results = await syncPendingPayments();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Sync completed",
|
||||||
|
processedCount: results.length,
|
||||||
|
results: results
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Sync API Error]:', error.message);
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/transactions/[id]/intent/route.ts
Normal file
30
app/api/transactions/[id]/intent/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const { network, token } = await req.json();
|
||||||
|
|
||||||
|
if (!id || !network || !token) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata with intent
|
||||||
|
// Using jsonb_set to merge or set properly
|
||||||
|
await db.query(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET metadata = metadata || jsonb_build_object('intent_network', $2::text, 'intent_token', $3::text)
|
||||||
|
WHERE id = $1
|
||||||
|
`, [id, network, token]);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[Intent Update Error]:", error.message);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
check_schema.js
Normal file
16
check_schema.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
const { Client } = require('pg');
|
||||||
|
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
async function checkSchema() {
|
||||||
|
await client.connect();
|
||||||
|
const res = await client.query(`
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'transactions'
|
||||||
|
`);
|
||||||
|
console.log(JSON.stringify(res.rows, null, 2));
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSchema();
|
||||||
60
components/admin/SyncPaymentsButton.tsx
Normal file
60
components/admin/SyncPaymentsButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { RefreshCw, Check, AlertCircle } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function SyncPaymentsButton() {
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (status === 'loading') return;
|
||||||
|
|
||||||
|
setStatus('loading');
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/sync-payments', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setResult(data);
|
||||||
|
setStatus('success');
|
||||||
|
router.refresh();
|
||||||
|
setTimeout(() => setStatus('idle'), 3000);
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{status === 'success' && result && (
|
||||||
|
<div className="text-[10px] font-black text-emerald-600 bg-emerald-50 px-3 py-1.5 rounded-lg border border-emerald-100 flex items-center gap-2 animate-in fade-in slide-in-from-right-2">
|
||||||
|
<Check size={12} />
|
||||||
|
{result.processedCount} İŞLEM TARANDI
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
className={`flex items-center justify-center gap-2 px-6 py-3 rounded-2xl text-sm font-bold transition shadow-lg ${
|
||||||
|
status === 'loading' ? 'bg-gray-100 text-gray-400 cursor-not-allowed' :
|
||||||
|
status === 'error' ? 'bg-red-50 text-red-600 border border-red-100' :
|
||||||
|
'bg-emerald-600 text-white hover:bg-emerald-700 shadow-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={status === 'loading' ? 'animate-spin' : ''} />
|
||||||
|
{status === 'loading' ? 'Senkronize Ediliyor...' : 'Ödemeleri Eşitle'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -108,6 +108,29 @@ export default function CryptoCheckout({ amount, currency, txId, wallets, onSucc
|
|||||||
setSelectedToken(network.tokens[0]);
|
setSelectedToken(network.tokens[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save payment intent to DB so background sync knows what to check
|
||||||
|
useEffect(() => {
|
||||||
|
if (!txId || !selectedNetwork || !selectedToken) return;
|
||||||
|
|
||||||
|
const saveIntent = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/transactions/${txId}/intent`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
network: selectedNetwork.id,
|
||||||
|
token: selectedToken.symbol
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save payment intent:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(saveIntent, 1000); // Debounce
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [txId, selectedNetwork.id, selectedToken.symbol]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(depositAddress);
|
navigator.clipboard.writeText(depositAddress);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
|||||||
152
lib/sync-worker.ts
Normal file
152
lib/sync-worker.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
|
||||||
|
import { CryptoEngine } from './crypto-engine';
|
||||||
|
import { db } from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background Sync Worker
|
||||||
|
* Checks blockchain for pending payments and completes them if detected.
|
||||||
|
*/
|
||||||
|
export async function syncPendingPayments() {
|
||||||
|
console.log("[SyncWorker] Starting manual sync...");
|
||||||
|
|
||||||
|
// 1. Fetch pending transactions that have an intent (or check all crypto transactions)
|
||||||
|
// We filter for transactions with statuses that indicate they are waiting for payment
|
||||||
|
// and have a provider type that suggests they are crypto (or just check metadata.wallets)
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT * FROM transactions
|
||||||
|
WHERE status IN ('waiting', 'pending')
|
||||||
|
AND (metadata->>'wallets' IS NOT NULL)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
const pendingTxs = result.rows;
|
||||||
|
console.log(`[SyncWorker] Found ${pendingTxs.length} pending crypto transactions.`);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// 2. Fetch System Settings once
|
||||||
|
const settingsResult = await db.query('SELECT key, value FROM system_settings WHERE key IN (\'sol_platform_address\', \'evm_platform_address\', \'tron_platform_address\', \'btc_platform_address\', \'default_fee_percent\')');
|
||||||
|
const systemMap: Record<string, string> = {};
|
||||||
|
settingsResult.rows.forEach(r => systemMap[r.key] = r.value);
|
||||||
|
|
||||||
|
const sysSettings = {
|
||||||
|
sol: systemMap.sol_platform_address || process.env.SOL_PLATFORM_ADDRESS || "Ajr4nKieZJVu9q2d1eVF9pQPRCLoZ6v4tapB3iQn2SyQ",
|
||||||
|
evm: systemMap.evm_platform_address || process.env.EVM_PLATFORM_ADDRESS || "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
|
||||||
|
tron: systemMap.tron_platform_address || process.env.TRON_PLATFORM_ADDRESS || "TLYpfG6rre8Gv9m8pYjR7yvX7S9rK6G1P",
|
||||||
|
btc: systemMap.btc_platform_address || process.env.BTC_PLATFORM_ADDRESS || "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfJH",
|
||||||
|
fee: parseFloat(systemMap.default_fee_percent || '1.0')
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tx of pendingTxs) {
|
||||||
|
try {
|
||||||
|
const metadata = tx.metadata || {};
|
||||||
|
const intentNetwork = metadata.intent_network || 'POLYGON';
|
||||||
|
const intentToken = metadata.intent_token || 'USDT';
|
||||||
|
|
||||||
|
console.log(`[SyncWorker] Syncing TX ${tx.id} | Intent: ${intentNetwork}/${intentToken}`);
|
||||||
|
|
||||||
|
// Re-use logic from crypto-sweep but optimized for background
|
||||||
|
const wallets = metadata.wallets || {};
|
||||||
|
const tempWalletConfig = wallets[intentNetwork] || wallets['EVM'];
|
||||||
|
|
||||||
|
if (!tempWalletConfig) continue;
|
||||||
|
|
||||||
|
const depositAddress = typeof tempWalletConfig === 'string' ? tempWalletConfig : tempWalletConfig.address;
|
||||||
|
const depositPrivateKey = tempWalletConfig.privateKey;
|
||||||
|
|
||||||
|
if (!depositPrivateKey) continue;
|
||||||
|
|
||||||
|
const cryptoEngine = new CryptoEngine(intentNetwork);
|
||||||
|
|
||||||
|
// Get expected amount logic
|
||||||
|
let expectedCryptoAmount = tx.amount.toString();
|
||||||
|
try {
|
||||||
|
const coinIdMap: Record<string, string> = {
|
||||||
|
'SOL': 'solana', 'USDC': 'usd-coin', 'USDT': 'tether', 'TRX': 'tron', 'BTC': 'bitcoin'
|
||||||
|
};
|
||||||
|
const coinId = coinIdMap[intentToken] || 'solana';
|
||||||
|
const priceUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd,try`;
|
||||||
|
const priceRes = await fetch(priceUrl);
|
||||||
|
const priceData = await priceRes.json();
|
||||||
|
const currencyKey = (tx.currency || 'TRY').toLowerCase();
|
||||||
|
const priceInCurrency = priceData[coinId][currencyKey] || priceData[coinId]['usd'];
|
||||||
|
|
||||||
|
if (priceInCurrency) {
|
||||||
|
const rawExpected = parseFloat(tx.amount) / priceInCurrency;
|
||||||
|
expectedCryptoAmount = (rawExpected * 0.98).toFixed(6);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
const verification = await cryptoEngine.verifyPayment(depositAddress, expectedCryptoAmount, intentToken);
|
||||||
|
|
||||||
|
if (verification.success) {
|
||||||
|
console.log(`[SyncWorker] Payment DETECTED for TX ${tx.id}. Sweeping...`);
|
||||||
|
|
||||||
|
let platformAddress = sysSettings.evm;
|
||||||
|
if (intentNetwork === 'SOLANA') platformAddress = sysSettings.sol;
|
||||||
|
else if (intentNetwork === 'TRON') platformAddress = sysSettings.tron;
|
||||||
|
else if (intentNetwork === 'BITCOIN') platformAddress = sysSettings.btc;
|
||||||
|
|
||||||
|
// Sweep
|
||||||
|
const sweepResult = await cryptoEngine.sweepFunds(depositPrivateKey, platformAddress, intentToken);
|
||||||
|
|
||||||
|
if (sweepResult.success) {
|
||||||
|
// Update Balances & Transaction (Same as crypto-sweep logic)
|
||||||
|
const merchantResult = await db.query('SELECT * FROM merchants WHERE id = $1', [tx.merchant_id]);
|
||||||
|
const merchant = merchantResult.rows[0];
|
||||||
|
const feePercent = merchant?.fee_percent !== undefined && merchant?.fee_percent !== null ? parseFloat(merchant.fee_percent) : sysSettings.fee;
|
||||||
|
|
||||||
|
const grossAmount = parseFloat(tx.amount);
|
||||||
|
const feeAmount = (grossAmount * feePercent) / 100;
|
||||||
|
const merchantNetCredit = grossAmount - feeAmount;
|
||||||
|
|
||||||
|
const cryptoAmount = parseFloat(expectedCryptoAmount);
|
||||||
|
const cryptoFee = (cryptoAmount * feePercent) / 100;
|
||||||
|
const cryptoNetCredit = cryptoAmount - cryptoFee;
|
||||||
|
|
||||||
|
// Execute DB updates in transaction if possible, or sequential
|
||||||
|
await db.query(`UPDATE merchants SET available_balance = available_balance + $1 WHERE id = $2`, [merchantNetCredit, tx.merchant_id]);
|
||||||
|
|
||||||
|
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
|
||||||
|
`, [tx.merchant_id, intentNetwork, intentToken, cryptoNetCredit, cryptoAmount]);
|
||||||
|
|
||||||
|
await db.query(`
|
||||||
|
UPDATE transactions
|
||||||
|
SET status = 'succeeded', paid_network = $2, paid_token = $3, paid_amount_crypto = $4
|
||||||
|
WHERE id = $1`, [tx.id, intentNetwork, intentToken, expectedCryptoAmount]);
|
||||||
|
|
||||||
|
// Webhook
|
||||||
|
if (tx.callback_url) {
|
||||||
|
fetch(tx.callback_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'success',
|
||||||
|
txId: tx.stripe_pi_id,
|
||||||
|
orderRef: tx.source_ref_id,
|
||||||
|
hashes: { txHash: sweepResult.txHash }
|
||||||
|
})
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ id: tx.id, status: 'synced', txHash: sweepResult.txHash });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.push({ id: tx.id, status: 'no_payment' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SyncWorker] Error processing TX ${tx.id}:`, err.message);
|
||||||
|
results.push({ id: tx.id, status: 'error', error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user