58ca4e68db
- Go backend API with full CRUD for all modules (CRM, Sales, Finance, HR, Legal, Marketing, Support, Purchase, Inventory, Projects, Automation, Analytics) - Rust analytics service with parallel report generation - C runtime with POSIX shared memory IPC - PostgreSQL schema with 30+ tables, full migrations - Redis cache, sessions, pub/sub - Kafka event streaming with Zookeeper - WebSocket hub for real-time updates - Automation engine with cron jobs, workflows, event triggers - JWT authentication, multi-tenant from start - Docker Compose with all services - Nginx reverse proxy with rate limiting - Integration tests passing - Feature gap analysis against Fortnox/Odoo/Visma Refs: BOC-001
473 lines
15 KiB
JavaScript
473 lines
15 KiB
JavaScript
// ============================================================
|
|
// 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;
|