// ============================================================ // QZ TOKEN API SERVER // ============================================================ // Endpoints för: // - Zoomer wallet-hantering // - Token-transaktioner // - Mission-prissättning // - Payout-hantering // - Stripe Connect-integration const express = require('express'); const { Pool } = require('pg'); const crypto = require('crypto'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const app = express(); app.use(express.json()); // PostgreSQL pool const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false }); // Konstanta const QZ_TO_USD_RATE = 1.0; // 1 QZ TOKEN = 1 USD (fast kurs, test) const MIN_MISSION_PRICE = 0.5; // Minimum $0.50 per mission // ============================================================ // HJÄLPFUNKTIONER // ============================================================ function generateTxHash() { const timestamp = Date.now(); const random = crypto.randomBytes(16).toString('hex'); return `qz_tx_${timestamp}_${random}`; } function signTransaction(txData, privateKey) { const sign = crypto.createSign('SHA256'); sign.update(JSON.stringify(txData)); sign.end(); return sign.sign(privateKey, 'hex'); } function qzToUsd(qzAmount) { return qzAmount * QZ_TO_USD_RATE; } function usdToQz(usdAmount) { return usdAmount / QZ_TO_USD_RATE; } // ============================================================ // ZOOMER WALLET // ============================================================ // Skapa wallet för ny Zoomer app.post('/api/v1/wallets', async (req, res) => { const { zoomer_id, stripe_connect_account_id } = req.body; try { const result = await pool.query( `INSERT INTO zoomer_wallets (zoomer_id, stripe_connect_account_id, stripe_connect_status) VALUES ($1, $2, $3) RETURNING *`, [zoomer_id, stripe_connect_account_id, stripe_connect_account_id ? 'active' : 'pending'] ); // Skapa default payout schema (måndagar) await pool.query( `INSERT INTO payout_schedules (zoomer_id, wallet_id, day_of_week, time_of_day) VALUES ($1, $2, 1, '09:00:00')`, [zoomer_id, result.rows[0].id] ); res.json({ success: true, wallet: result.rows[0] }); } catch (error) { console.error('Error creating wallet:', error); res.status(500).json({ error: 'Failed to create wallet' }); } }); // Hämta wallet-info app.get('/api/v1/wallets/:zoomer_id', async (req, res) => { const { zoomer_id } = req.params; try { const result = await pool.query( `SELECT * FROM zoomer_balance WHERE zoomer_id = $1`, [zoomer_id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Wallet not found' }); } res.json({ success: true, wallet: result.rows[0] }); } catch (error) { console.error('Error fetching wallet:', error); res.status(500).json({ error: 'Failed to fetch wallet' }); } }); // ============================================================ // TOKEN TRANSAKTIONER // ============================================================ // Skapa mission reward (när uppdrag godkänns) app.post('/api/v1/transactions/mission-reward', async (req, res) => { const { zoomer_id, mission_id, observation_id, amount_qz_token, description } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); // Hämta wallet const walletResult = await client.query( `SELECT id FROM zoomer_wallets WHERE zoomer_id = $1`, [zoomer_id] ); if (walletResult.rows.length === 0) { throw new Error('Wallet not found'); } const wallet_id = walletResult.rows[0].id; const txHash = generateTxHash(); const amount_usd = qzToUsd(amount_qz_token); // Skapa transaktion const txResult = await client.query( `INSERT INTO token_transactions (tx_hash, tx_type, to_wallet_id, amount_qz_token, amount_usd, exchange_rate, mission_id, observation_id, description, status, confirmed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) RETURNING *`, [txHash, 'mission_reward', wallet_id, amount_qz_token, amount_usd, QZ_TO_USD_RATE, mission_id, observation_id, description, 'confirmed'] ); // Uppdatera wallet-balans await client.query( `UPDATE zoomer_wallets SET balance_qz_token = balance_qz_token + $1 WHERE id = $2`, [amount_qz_token, wallet_id] ); await client.query('COMMIT'); res.json({ success: true, transaction: txResult.rows[0], message: `Rewarded ${amount_qz_token} QZ TOKEN ($${amount_usd.toFixed(2)}) to zoomer ${zoomer_id}` }); } catch (error) { await client.query('ROLLBACK'); console.error('Error creating mission reward:', error); res.status(500).json({ error: 'Failed to create mission reward' }); } finally { client.release(); } }); // Hämta transaktionshistorik app.get('/api/v1/transactions/:zoomer_id', async (req, res) => { const { zoomer_id } = req.params; const { limit = 50, offset = 0 } = req.query; try { const walletResult = await pool.query( `SELECT id FROM zoomer_wallets WHERE zoomer_id = $1`, [zoomer_id] ); if (walletResult.rows.length === 0) { return res.status(404).json({ error: 'Wallet not found' }); } const wallet_id = walletResult.rows[0].id; const result = await pool.query( `SELECT * FROM token_transactions WHERE from_wallet_id = $1 OR to_wallet_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, [wallet_id, limit, offset] ); res.json({ success: true, transactions: result.rows }); } catch (error) { console.error('Error fetching transactions:', error); res.status(500).json({ error: 'Failed to fetch transactions' }); } }); // ============================================================ // MISSION PRICING // ============================================================ // Hämta pris för mission-typ app.get('/api/v1/pricing/:mission_type', async (req, res) => { const { mission_type } = req.params; const { country_code, city } = req.query; try { let query = `SELECT * FROM mission_pricing WHERE mission_type = $1 AND status = 'active'`; let params = [mission_type]; if (country_code) { query += ` AND (country_code = $2 OR country_code IS NULL)`; params.push(country_code); } if (city) { query += ` AND (city = $3 OR city IS NULL)`; params.push(city); } query += ` ORDER BY country_code DESC, city DESC LIMIT 1`; const result = await pool.query(query, params); if (result.rows.length === 0) { // Returnera default-pris return res.json({ success: true, pricing: { mission_type, base_price_qz_token: usdToQz(MIN_MISSION_PRICE), base_price_usd: MIN_MISSION_PRICE, complexity_multiplier: 1.0, min_price_qz_token: usdToQz(MIN_MISSION_PRICE), max_price_qz_token: 100.0 } }); } res.json({ success: true, pricing: result.rows[0] }); } catch (error) { console.error('Error fetching pricing:', error); res.status(500).json({ error: 'Failed to fetch pricing' }); } }); // ============================================================ // PAYOUT // ============================================================ // Initiera payout (körs varje måndag via cron) app.post('/api/v1/payouts/execute', async (req, res) => { const { zoomer_id } = req.body; // Optional: specific zoomer, or all try { // Hitta zoomers med payout idag const today = new Date().getDay(); // 1 = Monday let query = ` SELECT ps.*, zw.balance_qz_token, zw.stripe_connect_account_id, z.name as zoomer_name FROM payout_schedules ps JOIN zoomer_wallets zw ON zw.id = ps.wallet_id JOIN zoomers z ON z.id = ps.zoomer_id WHERE ps.day_of_week = $1 AND ps.status = 'active' AND zw.stripe_connect_status = 'active' AND zw.balance_qz_token >= ps.minimum_qz_token `; let params = [today]; if (zoomer_id) { query += ` AND ps.zoomer_id = $2`; params.push(zoomer_id); } const schedules = await pool.query(query, params); const results = []; for (const schedule of schedules.rows) { try { const payoutResult = await executePayout(schedule); results.push(payoutResult); } catch (error) { results.push({ zoomer_id: schedule.zoomer_id, status: 'failed', error: error.message }); } } res.json({ success: true, payouts_executed: results.length, results }); } catch (error) { console.error('Error executing payouts:', error); res.status(500).json({ error: 'Failed to execute payouts' }); } }); // Utför payout via Stripe Connect async function executePayout(schedule) { const client = await pool.connect(); try { await client.query('BEGIN'); const amount_qz_token = schedule.balance_qz_token; const amount_usd = qzToUsd(amount_qz_token); // Skapa Stripe Transfer const transfer = await stripe.transfers.create({ amount: Math.round(amount_usd * 100), // Stripe använder cent currency: 'usd', destination: schedule.stripe_connect_account_id, description: `quiXzoom payout for ${schedule.zoomer_name}` }); // Skapa payout record const payoutResult = await client.query( `INSERT INTO payout_executions (zoomer_id, wallet_id, amount_qz_token, amount_usd, exchange_rate, stripe_transfer_id, status, processed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING *`, [schedule.zoomer_id, schedule.wallet_id, amount_qz_token, amount_usd, QZ_TO_USD_RATE, transfer.id, 'completed'] ); // Skapa token transaction (burn/payout) const txHash = generateTxHash(); await client.query( `INSERT INTO token_transactions (tx_hash, tx_type, from_wallet_id, amount_qz_token, amount_usd, exchange_rate, stripe_payout_id, description, status, confirmed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`, [txHash, 'payout', schedule.wallet_id, amount_qz_token, amount_usd, QZ_TO_USD_RATE, payoutResult.rows[0].id, `Weekly payout to ${schedule.zoomer_name}`, 'confirmed'] ); // Nollställ wallet-balans await client.query( `UPDATE zoomer_wallets SET balance_qz_token = 0 WHERE id = $1`, [schedule.wallet_id] ); await client.query('COMMIT'); return { zoomer_id: schedule.zoomer_id, status: 'completed', amount_qz_token, amount_usd, stripe_transfer_id: transfer.id }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } // ============================================================ // CUSTOMER TOP-UP // ============================================================ // Skapa top-up (kund köper QZ TOKENS) app.post('/api/v1/topups', async (req, res) => { const { customer_id, amount_usd } = req.body; try { const amount_qz_token = usdToQz(amount_usd); // Skapa Stripe Payment Intent const paymentIntent = await stripe.paymentIntents.create({ amount: Math.round(amount_usd * 100), currency: 'usd', customer: customer_id, description: `quiXzoom Top-up: ${amount_qz_token} QZ TOKEN` }); const result = await pool.query( `INSERT INTO customer_topups (customer_id, amount_qz_token, amount_usd, exchange_rate, stripe_payment_intent_id, status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [customer_id, amount_qz_token, amount_usd, QZ_TO_USD_RATE, paymentIntent.id, 'pending'] ); res.json({ success: true, topup: result.rows[0], client_secret: paymentIntent.client_secret }); } catch (error) { console.error('Error creating top-up:', error); res.status(500).json({ error: 'Failed to create top-up' }); } }); // Webhook för Stripe-händelser app.post('/api/v1/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; try { const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET); switch (event.type) { case 'payment_intent.succeeded': await handlePaymentSuccess(event.data.object); break; case 'transfer.paid': await handleTransferPaid(event.data.object); break; default: console.log(`Unhandled event type: ${event.type}`); } res.json({ received: true }); } catch (error) { console.error('Webhook error:', error); res.status(400).send(`Webhook Error: ${error.message}`); } }); async function handlePaymentSuccess(paymentIntent) { await pool.query( `UPDATE customer_topups SET status = 'completed', completed_at = NOW() WHERE stripe_payment_intent_id = $1`, [paymentIntent.id] ); } async function handleTransferPaid(transfer) { await pool.query( `UPDATE payout_executions SET status = 'completed', completed_at = NOW() WHERE stripe_transfer_id = $1`, [transfer.id] ); } // ============================================================ // HEALTH CHECK // ============================================================ app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'qz-token-api' }); }); // Starta server const PORT = process.env.PORT || 8080; app.listen(PORT, () => { console.log(`QZ Token API running on port ${PORT}`); }); module.exports = app;