// Betstack Pro — Cloudflare Worker backend (Stage 1: auth + D1) // Honest, non-custodial app: no deposits, no held funds, no investment logic. // Binding required: env.DB (your existing D1 database) // Secrets / vars: JWT_SECRET, OTP_PEPPER, FRONTEND_ORIGIN // RESEND_API_KEY (optional, for real OTP email), MAIL_FROM (optional) const enc = new TextEncoder(); // ---------- small helpers ---------- function b64(bytes){ const a=new Uint8Array(bytes); let s=''; for(let i=0;ib.toString(16).padStart(2,'0')).join(''); } // Server-side price catalog — never trust prices sent by the browser. const CATALOG = { 'code:1': { title:'Weekend Banker', amount:7, code:'B365-4K72PX', kind:'code' }, 'code:2': { title:'Over Goals Special', amount:9, code:'PM-9QX3M1', kind:'code' }, 'code:3': { title:'Low-Risk Single', amount:5, code:'1X-77ADE2', kind:'code' }, 'code:4': { title:'Acca of the Day', amount:12, code:'BW-LM55QZ', kind:'code' }, 'code:5': { title:'Draw Hunter', amount:6, code:'MB-DH22XK', kind:'code' }, 'code:6': { title:'BTTS Builder', amount:8, code:'B365-BT41QM', kind:'code' }, 'code:7': { title:'Safe Banker Pair', amount:5, code:'1X-SB09PA', kind:'code' }, 'code:8': { title:'Goals Galore', amount:10, code:'BW-GG88LX', kind:'code' }, 'code:9': { title:'Cup Night Special', amount:8, code:'PM-CN53RT', kind:'code' }, 'code:10': { title:'Underdog Value', amount:11, code:'MB-UD27VK', kind:'code' }, 'code:11': { title:'Tennis Straight', amount:5, code:'1X-TN66ZQ', kind:'code' }, 'code:12': { title:'Mega Acca', amount:15, code:'BW-MA99QD', kind:'code' }, 'tier:bronze':{ title:'Bronze membership', amount:15, kind:'tier', tier:'bronze' }, 'tier:silver':{ title:'Silver membership', amount:26, kind:'tier', tier:'silver' }, 'tier:gold': { title:'Gold membership', amount:49, kind:'tier', tier:'gold' }, 'tier:vip': { title:'VIP membership', amount:98, kind:'tier', tier:'vip' }, }; // Recursively sort object keys (NOWPayments signs the key-sorted JSON body). function sortKeys(o){ if(Array.isArray(o)) return o.map(sortKeys); if(o && typeof o==='object'){ const r={}; for(const k of Object.keys(o).sort()) r[k]=sortKeys(o[k]); return r; } return o; } async function hmacSha512Hex(data, secret){ const key = await crypto.subtle.importKey('raw', enc.encode(secret), {name:'HMAC', hash:'SHA-512'}, false, ['sign']); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data)); return [...new Uint8Array(sig)].map(b=>b.toString(16).padStart(2,'0')).join(''); } async function hmac(data, secret){ const key = await crypto.subtle.importKey('raw', enc.encode(secret), {name:'HMAC', hash:'SHA-256'}, false, ['sign']); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data)); return b64url(String.fromCharCode(...new Uint8Array(sig))); } function timingSafeEqual(a, b){ if(a.length!==b.length) return false; let r=0; for(let i=0;ip.exp) return null; return p; }catch{ return null; } } const reEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const reUser = /^(?=.*[A-Z])[A-Za-z0-9_]{8,}$/; function validPassword(p){ return typeof p==='string' && p.length>=8 && /[A-Z]/.test(p) && /[0-9]/.test(p) && /[^A-Za-z0-9]/.test(p); } function cookies(req){ const h=req.headers.get('Cookie')||''; const o={}; h.split(';').forEach(p=>{ const i=p.indexOf('='); if(i>0) o[p.slice(0,i).trim()]=p.slice(i+1).trim(); }); return o; } // Session token: prefer the Authorization: Bearer header (works cross-site on Safari), fall back to cookie. function tokenFrom(req){ const h=req.headers.get('Authorization')||''; if(h.startsWith('Bearer ')) return h.slice(7).trim(); return cookies(req).session || ''; } // Returns the admin's user id, or null if the caller isn't a signed-in admin. async function requireAdmin(req, env, secret){ const sess = await readSession(tokenFrom(req), secret); if(!sess) return null; const u = await env.DB.prepare(`SELECT id, role FROM users WHERE id=?`).bind(sess.uid).first(); return (u && u.role==='admin') ? u.id : null; } // Save an uploaded file (from multipart form-data) into R2, return its key. Null if no file. async function saveUpload(env, file){ if(!file || typeof file==='string') return null; if(file.size > 6*1024*1024) throw new Error('file_too_large'); const ext = (file.name && file.name.includes('.')) ? file.name.split('.').pop().toLowerCase().replace(/[^a-z0-9]/g,'') : 'bin'; const key = `uploads/${crypto.randomUUID()}.${ext}`; await env.BUCKET.put(key, await file.arrayBuffer(), { httpMetadata: { contentType: file.type || 'application/octet-stream' } }); return key; } function imgUrl(env, key){ return key ? `${env.PUBLIC_BASE || ''}/api/img/${key}` : null; } function genRefCode(){ const a='ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let s=''; for(let i=0;i<7;i++) s+=a[Math.floor(Math.random()*a.length)]; return s; } // ---------- Telegram helpers (partner engine add-on) ---------- // Send a message via the Bot API. Never throws — bot failures must not break the app. async function tgSend(env, chatId, text, extra){ try{ const r = await fetch(`https://api.telegram.org/bot${(env.BOT_TOKEN||'').trim()}/sendMessage`, { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ chat_id: chatId, text, parse_mode:'HTML', disable_web_page_preview:true, ...(extra||{}) }) }); if(!r.ok){ console.log('tgSend FAILED', r.status, await r.text()); } }catch(e){ console.log('tgSend error:', String(e)); } } // The Betstack user linked to this Telegram id, or null. async function tgUser(env, telegramId){ try{ return await env.DB.prepare( `SELECT u.id,u.username,u.tier,u.role,u.ref_code,COALESCE(u.ref_balance,0) AS ref_balance,COALESCE(u.payout_count,0) AS payout_count FROM tg_links t JOIN users u ON u.id=t.user_id WHERE t.telegram_id=?` ).bind(telegramId).first(); }catch(e){ return null; } } // Active subscriber = a real paid tier. 'none' (or missing) is not active. function tierActive(u){ return !!(u && ((u.tier && u.tier!=='none') || u.role==='admin')); } // Post to the Betstack Pro Telegram group. Best-effort; never throws. // Decide an unlock request (console endpoint AND Telegram buttons). async function decideUnlock(env, reqId, action){ const r = await env.DB.prepare(`SELECT * FROM unlock_requests WHERE id=?`).bind(reqId).first(); if(!r) return { ok:false, error:'not_found' }; if(r.status!=='pending') return { ok:false, error:'already_decided', status:r.status }; const status = action==='approved' ? 'approved' : 'rejected'; await env.DB.prepare(`UPDATE unlock_requests SET status=?, decided_at=? WHERE id=? AND status='pending'`).bind(status, Date.now(), reqId).run(); try{ const link = await env.DB.prepare(`SELECT telegram_id FROM tg_links WHERE user_id=?`).bind(r.user_id).first(); if(link){ const c = await env.DB.prepare(`SELECT title FROM codes WHERE id=?`).bind(r.code_id).first(); await tgSend(env, link.telegram_id, status==='approved' ? `\u2705 Your tip${c?` "${c.title}"`:''} is confirmed fresh \u2014 open the app to view the games.` : `\u26A0\uFE0F Your tip request${c?` "${c.title}"`:''} was declined \u2014 those games may have started. Check the app for today's tips.`); } }catch(e){} return { ok:true, status }; } // DM every linked admin with one-tap Approve/Reject buttons. async function dmAdmins(env, text, reqId){ try{ const { results } = await env.DB.prepare(`SELECT t.telegram_id FROM tg_links t JOIN users u ON u.id=t.user_id WHERE u.role='admin'`).all(); for(const a of (results||[])){ await tgSend(env, a.telegram_id, text, reqId ? { reply_markup: { inline_keyboard: [[ { text:'\u2705 Approve', callback_data:`ul:a:${reqId}` }, { text:'\u274C Reject', callback_data:`ul:r:${reqId}` } ]] } } : undefined); } }catch(e){ console.log('dmAdmins error:', String(e)); } } // ===== SPORTS DATA (api-sports.io) — admin fixtures browser ===== // Top leagues only, per sport. Free plan: 100 req/day per sport API. // Date-based fetching: one call per day returns EVERY league worldwide for that sport. // No league list to maintain, nothing goes stale, and it costs ~7 calls per sport per refresh. const SPORT_CFG = { football: { host:'v3.football.api-sports.io', path:'/fixtures', odds:true }, basketball: { host:'v1.basketball.api-sports.io', path:'/games' }, nfl: { host:'v1.american-football.api-sports.io', path:'/games' }, baseball: { host:'v1.baseball.api-sports.io', path:'/games' }, hockey: { host:'v1.hockey.api-sports.io', path:'/games' }, volleyball: { host:'v1.volleyball.api-sports.io', path:'/games' }, }; let SPORTS_LAST_ERR = ''; // ===== football-data.org (free tier: 12 top comps, CURRENT season, 10 req/min) ===== // Competition codes: PL, PD, SA, BL1, FL1, CL, EL, ELC, DED, PPL, BSA, WC, EC const FD_COMPS = ['WC','CL','PL','PD','SA','BL1','FL1','EL','ELC','DED','PPL','BSA']; async function footballDataFetch(env, dateFrom, dateTo){ // One call returns ALL competitions' matches in the window (filtered to our set). const url = `https://api.football-data.org/v4/matches?dateFrom=${dateFrom}&dateTo=${dateTo}`; const r = await fetch(url, { headers:{ 'X-Auth-Token': (env.FOOTBALL_DATA_KEY||'').trim() } }); if(r.status===429){ SPORTS_LAST_ERR='HTTP 429 — football-data.org rate limit (10/min). Wait a minute.'; return []; } if(r.status===403){ SPORTS_LAST_ERR='HTTP 403 — football-data.org key missing/invalid, or competition not in free tier.'; return []; } if(!r.ok){ SPORTS_LAST_ERR='football-data.org HTTP '+r.status; return []; } const d = await r.json().catch(()=>null); return (d && Array.isArray(d.matches)) ? d.matches : []; } // ===== the-odds-api.com — real bookmaker odds, on demand (free tier: small monthly quota) ===== // Sport keys map our tabs to the-odds-api sport keys. const ODDS_SPORTS = { football: 'soccer_epl', // (soccer has many leagues; we query a few below) basketball: 'basketball_nba', nfl: 'americanfootball_nfl', baseball: 'baseball_mlb', hockey: 'icehockey_nhl', tennis: 'tennis_atp', mma: 'mma_mixed_martial_arts', }; const ODDS_SOCCER_LEAGUES = ['soccer_uefa_champs_league','soccer_epl','soccer_spain_la_liga','soccer_italy_serie_a','soccer_germany_bundesliga','soccer_france_ligue_one','soccer_fifa_world_cup']; async function theOddsApi(env, sportKey){ if(!env.ODDS_API_KEY) { SPORTS_LAST_ERR='ODDS_API_KEY not set'; return []; } const url=`https://api.the-odds-api.com/v4/sports/${sportKey}/odds/?regions=uk,eu&markets=h2h,totals&oddsFormat=decimal&apiKey=${(env.ODDS_API_KEY||'').trim()}`; const r=await fetch(url); if(r.status===401){ SPORTS_LAST_ERR='HTTP 401 — the-odds-api key invalid'; return []; } if(r.status===429){ SPORTS_LAST_ERR='HTTP 429 — the-odds-api quota reached (free tier is small)'; return []; } if(!r.ok){ SPORTS_LAST_ERR='the-odds-api HTTP '+r.status; return []; } const d=await r.json().catch(()=>null); return Array.isArray(d)?d:[]; } // Refresh one sport's games+odds via the-odds-api into the fixtures table. Cached by caller. async function refreshOddsSport(env, sport){ SPORTS_LAST_ERR=''; if(!env.ODDS_API_KEY) return { ok:false, error:'no_odds_key', detail:'Add ODDS_API_KEY (the-odds-api.com) to the Worker.' }; const now=Date.now(); const keys = sport==='football' ? ODDS_SOCCER_LEAGUES : [ODDS_SPORTS[sport]].filter(Boolean); if(!keys.length) return { ok:false, error:'sport_unsupported' }; let saved=0, seen=0; for(const key of keys){ const games=await theOddsApi(env, key); seen+=games.length; for(const g of games){ const home=g.home_team||'', away=g.away_team||''; const ts=Date.parse(g.commence_time)||0; if(!home||!away||!ts) continue; const id='oa-'+g.id; // Extract h2h + totals from first bookmaker const out={}; const bk=(g.bookmakers&&g.bookmakers[0])||null; if(bk){ for(const m of (bk.markets||[])){ if(m.key==='h2h'){ out.h2h={}; for(const o of (m.outcomes||[])){ if(o.name===home)out.h2h.Home=o.price; else if(o.name===away)out.h2h.Away=o.price; else out.h2h.Draw=o.price; } } if(m.key==='totals'){ const over=(m.outcomes||[]).find(o=>o.name==='Over'); if(over){ out.ou_line=over.point; out.ou_over=over.price; } } } } try{ await env.DB.prepare(`INSERT INTO fixtures(id,ext_id,sport,league,home,away,start_ts,status,odds,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET league=excluded.league,home=excluded.home,away=excluded.away,start_ts=excluded.start_ts,odds=excluded.odds,updated_at=excluded.updated_at`) .bind(id, g.id, sport, (g.sport_title||key), home, away, ts, 'scheduled', JSON.stringify(out), now).run(); saved++; }catch(e){ SPORTS_LAST_ERR=SPORTS_LAST_ERR||('db: '+String(e).slice(0,100)); } } } return { ok:true, saved, seen, source:'the-odds-api', api_error:SPORTS_LAST_ERR||null, note: saved===0 ? (SPORTS_LAST_ERR||'No games returned for this sport right now.') : null }; } // Auto-settlement: evaluate simple markets against final scores. Best-effort, conservative. function evalPick(pick, sh, sa){ // returns true (won), false (lost), or null (can't judge -> leave for manual) const p=(pick||'').toLowerCase(); const tot=sh+sa, home=sh>sa, away=sa>sh, draw=sh===sa, btts=sh>0&&sa>0; const ou=(n)=> p.includes('over '+n) ? (tot>n) : p.includes('under '+n) ? (tot can't judge from FT score, leave manual } async function autoSettle(env){ const finished = await env.DB.prepare(`SELECT home,away,score_home,score_away FROM fixtures WHERE score_home IS NOT NULL AND score_away IS NOT NULL AND start_ts < ?`).bind(Date.now()).all(); const games=(finished&&finished.results)||[]; if(!games.length) return; const norm=s=>(s||'').toLowerCase().replace(/[^a-z]/g,''); // Settle open predictions const preds = await env.DB.prepare(`SELECT id,match,pick FROM predictions WHERE status='pending' OR status='open' OR status IS NULL`).all(); for(const pr of ((preds&&preds.results)||[])){ const m=(pr.match||'').split(/ vs | v /i); if(m.length<2) continue; const h=norm(m[0]), a=norm(m[1]); const g=games.find(x=>norm(x.home).includes(h.slice(0,6))||h.includes(norm(x.home).slice(0,6))); if(!g) continue; const res=evalPick(pr.pick, g.score_home, g.score_away); if(res===null) continue; await env.DB.prepare(`UPDATE predictions SET status=?, settled_at=datetime('now') WHERE id=?`).bind(res?'won':'lost', pr.id).run(); try{ await tgGroup(env, `${res?'\u2705':'\u274C'} Prediction ${res?'WON':'LOST'}\n${pr.match}\n${pr.pick} \u2014 result ${g.score_home}\u2013${g.score_away}`); }catch(e){} } } async function refreshFootballFD(env){ SPORTS_LAST_ERR=''; if(!env.FOOTBALL_DATA_KEY) return { ok:false, error:'no_key', detail:'FOOTBALL_DATA_KEY is not set on this Worker.' }; const now=Date.now(); const dateFrom=new Date(now).toISOString().slice(0,10); const dateTo=new Date(now+10*86400000).toISOString().slice(0,10); let saved=0, seen=0; const matches = await footballDataFetch(env, dateFrom, dateTo); seen = matches.length; for(const m of matches){ // Save every match football-data returns for our tier (no code filtering — // the API already scopes to the plan's competitions). const home=(m.homeTeam&&(m.homeTeam.shortName||m.homeTeam.name))||''; const away=(m.awayTeam&&(m.awayTeam.shortName||m.awayTeam.name))||''; const ts=Date.parse(m.utcDate)||0; if(!home||!away||!ts) continue; const id='fd-'+m.id; const st=(m.status||'').slice(0,12); const sh=(m.score&&m.score.fullTime&&m.score.fullTime.home!=null)?m.score.fullTime.home:null; const sa=(m.score&&m.score.fullTime&&m.score.fullTime.away!=null)?m.score.fullTime.away:null; try{ await env.DB.prepare(`INSERT INTO fixtures(id,ext_id,sport,league,home,away,start_ts,status,score_home,score_away,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET league=excluded.league,home=excluded.home,away=excluded.away,start_ts=excluded.start_ts,status=excluded.status,score_home=excluded.score_home,score_away=excluded.score_away,updated_at=excluded.updated_at`) .bind(id, String(m.id), 'football', (m.competition&&m.competition.name)||'', home, away, ts, st, sh, sa, now).run(); saved++; }catch(e){ SPORTS_LAST_ERR = SPORTS_LAST_ERR || ('db: '+String(e).slice(0,120)); } } return { ok:true, saved, seen, source:'football-data.org', api_error: SPORTS_LAST_ERR||null, note: saved===0 ? (SPORTS_LAST_ERR || 'No matches in the next 10 days for the free-tier competitions.') : null }; } async function sportsApi(env, host, path, params){ const qs = new URLSearchParams(params).toString(); const r = await fetch(`https://${host}${path}?${qs}`, { headers:{ 'x-apisports-key': (env.SPORTS_API_KEY||'').trim() } }); if(r.status===404){ SPORTS_LAST_ERR='HTTP 404 — your API key is not subscribed to this sport. Enable it (free) in your api-sports dashboard.'; return []; } if(r.status===403){ SPORTS_LAST_ERR='HTTP 403 — key rejected or plan limit.'; return []; } if(r.status===429){ SPORTS_LAST_ERR='HTTP 429 — daily request limit reached. Try again tomorrow.'; return []; } const d = await r.json().catch(()=>null); if(d && d.errors && !Array.isArray(d.errors) && Object.keys(d.errors).length){ SPORTS_LAST_ERR = JSON.stringify(d.errors).slice(0,180); } return (d && Array.isArray(d.response)) ? d.response : []; } function mapFixture(sport, f){ // Football shape vs other-sports shape if(sport==='football'){ const st=(f.fixture&&f.fixture.status&&f.fixture.status.short)||''; return { id:`fb-${f.fixture.id}`, ext_id:String(f.fixture.id), sport, league:(f.league&&f.league.name)||'', home:(f.teams&&f.teams.home&&f.teams.home.name)||'', away:(f.teams&&f.teams.away&&f.teams.away.name)||'', start_ts:Date.parse(f.fixture.date)||0, status:st, sh:(f.goals&&f.goals.home!=null)?f.goals.home:null, sa:(f.goals&&f.goals.away!=null)?f.goals.away:null }; } const home=(f.teams&&f.teams.home&&(f.teams.home.name||f.teams.home))||''; const away=(f.teams&&f.teams.away&&(f.teams.away.name||f.teams.away))||''; const st=(f.status&&(f.status.short||f.status))||''; return { id:`${sport.slice(0,2)}-${f.id}`, ext_id:String(f.id), sport, league:(f.league&&f.league.name)||'', home, away, start_ts:Date.parse(f.date)||0, status:typeof st==='string'?st:'' }; } // Refresh one sport: fixtures for the next 7 days (+ football odds for today/tomorrow). async function refreshSport(env, sport){ SPORTS_LAST_ERR=''; if(sport==='football') return await refreshFootballFD(env); // current-season via football-data.org const cfg=SPORT_CFG[sport]; if(!cfg) return { ok:false, error:'unknown_sport' }; if(!env.SPORTS_API_KEY) return { ok:false, error:'no_key', detail:'SPORTS_API_KEY is not set on this Worker.' }; const now=Date.now(); let saved=0, seen=0; // One call per day = every league in the world for that day. for(let d=0; d<7; d++){ const date=new Date(now + d*86400000).toISOString().slice(0,10); let rows=[]; try{ rows = await sportsApi(env, cfg.host, cfg.path, { date }); }catch(e){ continue; } seen += rows.length; for(const f of rows){ const m=mapFixture(sport,f); if(!m.home||!m.away||!m.start_ts) continue; try{ await env.DB.prepare(`INSERT INTO fixtures(id,ext_id,sport,league,home,away,start_ts,status,score_home,score_away,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET league=excluded.league,home=excluded.home,away=excluded.away,start_ts=excluded.start_ts,status=excluded.status,score_home=excluded.score_home,score_away=excluded.score_away,updated_at=excluded.updated_at`) .bind(m.id,m.ext_id,m.sport,m.league,m.home,m.away,m.start_ts,m.status,(m.sh==null?null:m.sh),(m.sa==null?null:m.sa),now).run(); saved++; }catch(e){ SPORTS_LAST_ERR = SPORTS_LAST_ERR || ('db: '+String(e).slice(0,120)); } } } // Fixtures are done. Preserve any real fixtures error, then attempt odds SEPARATELY. const fixturesErr = SPORTS_LAST_ERR; // error from the fixtures phase only SPORTS_LAST_ERR = ''; // odds errors must NOT mask fixtures success // Football odds for today + tomorrow — best-effort, never blocks or overrides fixtures. if(cfg.odds){ for(let d=0; d<2; d++){ const date=new Date(now+d*86400000).toISOString().slice(0,10); for(let page=1; page<=3; page++){ let rows=[]; try{ rows = await sportsApi(env, cfg.host, '/odds', { date, page }); }catch(e){ break; } if(!rows.length) break; for(const o of rows){ const fid=`fb-${o.fixture&&o.fixture.id}`; const bk=(o.bookmakers&&o.bookmakers[0])||null; if(!bk) continue; const out={}; for(const bet of (bk.bets||[])){ if(bet.name==='Match Winner') out.h2h=Object.fromEntries((bet.values||[]).map(v=>[v.value,v.odd])); if(bet.name==='Goals Over/Under'){ const v=(bet.values||[]).find(x=>String(x.value)==='Over 2.5'); if(v) out.ou25=v.odd; } if(bet.name==='Both Teams Score') out.btts=Object.fromEntries((bet.values||[]).map(v=>[v.value,v.odd])); } if(Object.keys(out).length){ try{ await env.DB.prepare(`UPDATE fixtures SET odds=? WHERE id=?`).bind(JSON.stringify(out),fid).run(); }catch(e){} } } } } } return { ok:true, saved, seen, api_error: fixturesErr || null, note: saved===0 ? (seen===0 ? (fixturesErr || 'The API returned no games for the next 7 days. If this persists it is usually a daily-quota limit (resets at 00:00 UTC).') : 'Games returned but none saved — likely a missing fixtures table/column; run the SQL pastes.') : null }; } async function tgGroup(env, text, extra){ if(!env.TG_GROUP_ID) return; await tgSend(env, env.TG_GROUP_ID, text, extra); } // Inline "bet at partner" button for group posts (only if AFF_MAP is configured). function betBtn(env){ if(!env.AFF_MAP) return undefined; const base=(env.WORKER_URL||'').replace(/\/$/,''); if(!base) return undefined; return { reply_markup:{ inline_keyboard:[[{ text:'\u26A1 Book at our partner \u2197', url: base+'/go/bet' }]] } }; } function corsHeaders(req, env){ const allowed = (env.FRONTEND_ORIGIN||'').split(',').map(s=>s.trim()).filter(Boolean); const origin = req.headers.get('Origin')||''; const allow = allowed.includes(origin) ? origin : (allowed[0]||'*'); return { 'Access-Control-Allow-Origin': allow, 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Vary': 'Origin' }; } function json(data, status, req, env, extra){ return new Response(JSON.stringify(data), { status: status||200, headers: { 'Content-Type':'application/json', ...corsHeaders(req,env), ...(extra||{}) } }); } function sessionCookie(token, maxAge){ return `session=${token}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=${maxAge}`; } function otpEmailHtml(code){ return `
Betstack Pro
Verify your email
Use the code below to finish signing in to your Betstack Pro account.
${code}
This code is valid for 10 minutes and can only be used once.
Never share this code. Betstack Pro staff will never ask you for it.
If you didn't request this, you can safely ignore this email.
— The Betstack Pro Team
© 2026 Betstack Pro · Bet responsibly · 18+
Questions? Just reply to this email.
`; } async function sendOtpEmail(env, dest, code){ const subject = 'Your Betstack Pro verification code'; const text = `Your Betstack Pro verification code is ${code}. It is valid for 10 minutes and can be used once. Never share this code — Betstack Pro staff will never ask for it. If you didn't request it, you can ignore this email. — The Betstack Pro Team`; if(env.RESEND_API_KEY){ try{ const r = await fetch('https://api.resend.com/emails', { method:'POST', headers:{ 'Authorization':`Bearer ${env.RESEND_API_KEY}`, 'Content-Type':'application/json' }, body: JSON.stringify({ from: env.MAIL_FROM || 'Betstack Pro ', to: [dest], reply_to: env.REPLY_TO || 'support@betstackpro.com', subject, html: otpEmailHtml(code), text }) }); if(r.ok){ console.log('OTP email sent OK to', dest); } else { console.log('Resend error:', r.status, await r.text()); } }catch(e){ console.log('Resend exception:', String(e)); } return; } console.log('OTP EMAIL SKIPPED — RESEND_API_KEY is not set on this Worker. Code for', dest, 'was', code); // set RESEND_API_KEY to send real emails } // ---------- Sports data (API-Sports) — admin fixtures browser ---------- // On-demand fetch with 12h D1 cache: ~1 API request per sport per day viewed. const SPORT_HOSTS = { football:'v3.football.api-sports.io', basketball:'v1.basketball.api-sports.io', nfl:'v1.american-football.api-sports.io', baseball:'v1.baseball.api-sports.io', hockey:'v1.hockey.api-sports.io', volleyball:'v1.volleyball.api-sports.io' }; // Curated top leagues per sport (empty list = show all for that date) const SPORT_LEAGUES = { football:[2,3,39,61,78,88,94,135,140,71,253,307,203,399,1,4], // UCL,UEL,EPL,L1,BUN,ERE,POR,SA,LaLiga,BRA,MLS,KSA,TUR,NPFL,WC,EURO basketball:[12,120], nfl:[1], baseball:[1], hockey:[57], volleyball:[] }; async function fetchSport(env, sport, path){ const host = SPORT_HOSTS[sport]; if(!host) return null; try{ const r = await fetch(`https://${host}${path}`, { headers:{ 'x-apisports-key': (env.SPORTS_API_KEY||'').trim() } }); if(!r.ok){ console.log('sports api', sport, r.status); return null; } return await r.json(); }catch(e){ console.log('sports api err', String(e)); return null; } } function mapFixtures(sport, data){ const out=[]; for(const it of (data&&data.response)||[]){ try{ if(sport==='football'){ out.push({ id:it.fixture.id, ts:it.fixture.date, status:(it.fixture.status&&it.fixture.status.short)||'NS', league:it.league.name, leagueId:it.league.id, country:it.league.country||'', home:it.teams.home.name, away:it.teams.away.name, homeId:it.teams.home.id, awayId:it.teams.away.id }); } else { const lg=it.league||{}; const cn=(it.country&&it.country.name)||lg.country||''; out.push({ id:it.id, ts:it.date||it.time||'', status:(it.status&&(it.status.short||it.status))||'NS', league:lg.name||'', leagueId:lg.id||0, country:cn, home:(it.teams&&it.teams.home&&it.teams.home.name)||'', away:(it.teams&&it.teams.away&&it.teams.away.name)||'' }); } }catch(e){} } const allow = SPORT_LEAGUES[sport]||[]; return allow.length ? out.filter(f=>allow.includes(f.leagueId)) : out; } // ---------- router ---------- export default { // Daily cron (set in dashboard: Settings → Triggers → Cron, e.g. "0 6 * * *"): // refreshes the week's fixtures + odds for every configured sport. async scheduled(event, env, ctx){ // 1) refresh football fixtures (also pulls final scores) try{ await refreshFootballFD(env); }catch(e){} // 2) auto-settle predictions/tips whose match has finished try{ await autoSettle(env); }catch(e){ console.log('autoSettle error', String(e)); } }, async fetch(req, env){ const url = new URL(req.url); const path = url.pathname; if(req.method==='OPTIONS') return new Response(null, { status:204, headers: corsHeaders(req, env) }); const JWT = env.JWT_SECRET || 'CHANGE_ME'; const PEPPER = env.OTP_PEPPER || 'CHANGE_ME'; try{ // TEMP public diagnostic (no auth) — delete after debugging. Tests football-data.org directly. if(path==='/api/_fdtest' && req.method==='GET'){ const out={ ok:true, has_key: !!env.FOOTBALL_DATA_KEY, key_len:(env.FOOTBALL_DATA_KEY||'').trim().length }; if(!out.has_key){ out.verdict='FOOTBALL_DATA_KEY is NOT set on this worker'; return json(out,200,req,env); } try{ const from=new Date().toISOString().slice(0,10); const to=new Date(Date.now()+10*86400000).toISOString().slice(0,10); const r=await fetch(`https://api.football-data.org/v4/matches?dateFrom=${from}&dateTo=${to}`,{ headers:{ 'X-Auth-Token':(env.FOOTBALL_DATA_KEY||'').trim() } }); out.http_status=r.status; const txt=await r.text(); out.body_snippet=txt.slice(0,400); try{ const j=JSON.parse(txt); out.match_count=Array.isArray(j.matches)?j.matches.length:'(no matches array)'; }catch(e){} out.verdict = r.status===200 ? ('WORKS \u2014 '+ (out.match_count||0) +' matches in next 10 days') : r.status===403 ? 'KEY REJECTED (403) \u2014 wrong/incomplete key' : r.status===429 ? 'RATE LIMITED (429) \u2014 wait 60s' : ('status '+r.status); }catch(e){ out.error=String(e).slice(0,200); out.verdict='fetch threw'; } return json(out,200,req,env); } if(path==='/health') return json({ ok:true, ts:Date.now(), build:'v-odds-1', routes:['fixtures','fixtures/refresh','fixture/stats','news','scores'] }, 200, req, env); // Quick check: does this worker have the admin fixtures route? if(path==='/api/_routes' && req.method==='GET') return json({ ok:true, build:'v-odds-1', has_admin_fixtures:true }, 200, req, env); // ============ PUBLIC MATCH CENTER + SITE STATS (cached, no auth) ============ // Reads OUR D1 fixtures (refreshed by cron) — visitors never touch the sports API. // Head-to-head + recent form for one fixture. Cached in D1 (12h) so repeat views cost no API quota. if(path==='/api/fixture/stats' && req.method==='GET'){ const fid=(url.searchParams.get('id')||'').trim(); if(!fid) return json({ ok:false, error:'id_required' }, 400, req, env); try{ const c=await env.DB.prepare(`SELECT data,updated_at FROM fixture_stats WHERE fixture_id=?`).bind(fid).first(); if(c && c.updated_at > Date.now()-12*3600000) return json({ ok:true, ...JSON.parse(c.data) }, 200, req, env, { 'Cache-Control':'public, max-age=3600' }); }catch(e){} const fx=await env.DB.prepare(`SELECT ext_id,sport FROM fixtures WHERE id=?`).bind(fid).first(); if(!fx) return json({ ok:false, error:'not_found' }, 404, req, env); const out={ h2h:[], form:{home:'',away:''} }; // Stats via api-sports only for its own fixtures (ids like fb-...). football-data // (fd-...) free tier doesn't include H2H/form, so we return an empty (honest) set. const isApiSports = String(fid).startsWith('fb-'); if(fx.sport==='football' && isApiSports && env.SPORTS_API_KEY){ try{ const cfg=SPORT_CFG.football; const one=await sportsApi(env,cfg.host,'/fixtures',{ id:fx.ext_id }); const t=one[0]&&one[0].teams; const hid=t&&t.home&&t.home.id, aid=t&&t.away&&t.away.id; if(hid&&aid){ const h2h=await sportsApi(env,cfg.host,'/fixtures/headtohead',{ h2h:`${hid}-${aid}`, last:5 }); out.h2h=(h2h||[]).map(m=>({ date:((m.fixture&&m.fixture.date)||'').slice(0,10), home:(m.teams&&m.teams.home&&m.teams.home.name)||'', away:(m.teams&&m.teams.away&&m.teams.away.name)||'', sh:(m.goals&&m.goals.home), sa:(m.goals&&m.goals.away) })).filter(m=>m.sh!=null); const formOf=async(tid)=>{ const rows=await sportsApi(env,cfg.host,'/fixtures',{ team:tid, last:5 }); return (rows||[]).map(m=>{ const isHome=m.teams&&m.teams.home&&m.teams.home.id===tid; const gh=m.goals&&m.goals.home, ga=m.goals&&m.goals.away; if(gh==null||ga==null) return ''; if(gh===ga) return 'D'; return (isHome?gh>ga:ga>gh)?'W':'L'; }).filter(Boolean).reverse().join(''); }; out.form.home=await formOf(hid); out.form.away=await formOf(aid); } }catch(e){ console.log('stats fetch failed',String(e)); } } try{ await env.DB.prepare(`INSERT INTO fixture_stats(fixture_id,data,updated_at) VALUES(?,?,?) ON CONFLICT(fixture_id) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at`).bind(fid,JSON.stringify(out),Date.now()).run(); }catch(e){} return json({ ok:true, ...out }, 200, req, env, { 'Cache-Control':'public, max-age=3600' }); } if(path==='/api/fixtures/public' && req.method==='GET'){ try{ const sport=(url.searchParams.get('sport')||'football').toLowerCase(); const now=Date.now(), dayEnd=now+2*86400000; const rows = await env.DB.prepare(`SELECT id,sport,league,home,away,start_ts,status FROM fixtures WHERE sport=? AND start_ts BETWEEN ? AND ? ORDER BY start_ts ASC LIMIT 40`).bind(sport, now-2*3600000, dayEnd).all(); return json({ ok:true, fixtures:(rows&&rows.results)||[] }, 200, req, env, { 'Cache-Control':'public, max-age=600' }); }catch(e){ return json({ ok:true, fixtures:[] }, 200, req, env); } } if(path==='/api/public-stats' && req.method==='GET'){ const out={ ok:true, won:0, lost:0, tips:0, matches_tracked:0 }; try{ const a=await env.DB.prepare(`SELECT SUM(status='won') w, SUM(status='lost') l FROM predictions WHERE settled_at > datetime('now','-30 days')`).first(); out.won=(a&&a.w)||0; out.lost=(a&&a.l)||0; }catch(e){} try{ const t=await env.DB.prepare(`SELECT COUNT(*) n FROM codes WHERE active=1`).first(); out.tips=(t&&t.n)||0; }catch(e){} try{ const f=await env.DB.prepare(`SELECT COUNT(*) n FROM fixtures WHERE start_ts > ?`).bind(Date.now()).first(); out.matches_tracked=(f&&f.n)||0; }catch(e){} return json(out, 200, req, env, { 'Cache-Control':'public, max-age=600' }); } // ============ PARTNER BET LINK (geo-router + chooser) ============ // AFF_MAP values per country can be: // "https://link" -> instant redirect (best conversion) // [{"name":"1xBet","url":"..."},{...}] -> branded chooser page (2-3 options) // Always include a "default" entry. Destinations are bookmakers YOU vetted. if((path==='/go/bet' || path==='/go/bet/out') && req.method==='GET'){ let map={}; try{ map=JSON.parse(env.AFF_MAP||'{}'); }catch(e){} const country=(req.cf&&req.cf.country)||'XX'; const entry=map[country]||map.default; const site=(env.FRONTEND_ORIGIN||'').split(',')[0].trim()||'https://betstackpro.com'; const logClick=async (bk)=>{ try{ await env.DB.prepare(`INSERT INTO go_clicks(id,country,bookmaker,created_at) VALUES(?,?,?,?)`).bind(crypto.randomUUID(),country,bk||null,Date.now()).run(); } catch(e){ try{ await env.DB.prepare(`INSERT INTO go_clicks(id,country,created_at) VALUES(?,?,?)`).bind(crypto.randomUUID(),country,Date.now()).run(); }catch(e2){} } }; const go=(dest,bk)=>{ return new Response(null,{ status:302, headers:{ 'Location':dest, 'Cache-Control':'no-store' } }); }; if(!entry){ await logClick('unconfigured'); return go(site+'/?goto=plans'); } // Chosen option from the chooser page if(path==='/go/bet/out'){ const i=parseInt(url.searchParams.get('i')||'0',10); const opt=Array.isArray(entry)?entry[i]:null; const dest=opt?opt.url:(typeof entry==='string'?entry:site); await logClick(opt?opt.name:'direct'); return go(dest); } // Single link -> instant redirect if(typeof entry==='string'){ await logClick('direct'); return go(entry); } if(Array.isArray(entry)&&entry.length===1){ await logClick(entry[0].name); return go(entry[0].url); } // Multiple options -> branded chooser page await logClick('chooser'); const opts=(Array.isArray(entry)?entry:[]).slice(0,4).map((o,i)=> `${String(o.name||'Bookmaker').replace(/[<>]/g,'')}Bet now \u2192`).join(''); const html=` Betstack Pro \u00B7 Choose your bookmaker
Pick a bookmaker to place your games \u2014 available in your country
${opts||'
No partners configured yet.
'}
Affiliate links \u00B7 We may earn a commission \u00B7 18+ only \u00B7 Bet responsibly \u2014 never stake more than you can afford to lose
`; return new Response(html,{ headers:{ 'Content-Type':'text/html;charset=utf-8','Cache-Control':'no-store' } }); } // Admin: click totals by country (last 30 days) if(path==='/api/admin/go-stats' && req.method==='GET'){ const adminId0 = await requireAdmin(req, env, JWT); if(!adminId0) return json({ ok:false, error:'forbidden' }, 403, req, env); try{ const rows = await env.DB.prepare(`SELECT country, COUNT(*) n FROM go_clicks WHERE created_at > ? GROUP BY country ORDER BY n DESC LIMIT 30`).bind(Date.now()-30*86400000).all(); let byBook=[]; try{ const bb=await env.DB.prepare(`SELECT bookmaker, COUNT(*) n FROM go_clicks WHERE created_at > ? AND bookmaker IS NOT NULL GROUP BY bookmaker ORDER BY n DESC LIMIT 20`).bind(Date.now()-30*86400000).all(); byBook=(bb&&bb.results)||[]; }catch(e){} const tot = await env.DB.prepare(`SELECT COUNT(*) n FROM go_clicks WHERE created_at > ?`).bind(Date.now()-30*86400000).first(); return json({ ok:true, total:(tot&&tot.n)||0, by_country:(rows&&rows.results)||[], by_bookmaker:byBook }, 200, req, env); }catch(e){ return json({ ok:true, total:0, by_country:[] }, 200, req, env); } } // --- send a verification code --- if(path==='/api/auth/request-code' && req.method==='POST'){ const { dest, channel } = await req.json(); if(!reEmail.test(dest||'')) return json({ ok:false, error:'invalid_dest' }, 400, req, env); const d = dest.toLowerCase(); const recent = await env.DB.prepare( `SELECT COUNT(*) AS n FROM otp_codes WHERE dest=? AND created_at > datetime('now','-10 minutes')` ).bind(d).first(); if(recent && recent.n >= 5) return json({ ok:false, error:'too_many_requests' }, 429, req, env); const code = String(crypto.getRandomValues(new Uint32Array(1))[0] % 1000000).padStart(6,'0'); const codeHash = await sha256hex(code + PEPPER); await env.DB.prepare(`INSERT INTO otp_codes(dest, channel, code_hash, expires_at) VALUES(?,?,?,?)`) .bind(d, channel||'email', codeHash, Date.now()+10*60*1000).run(); await sendOtpEmail(env, dest, code); // The code is delivered only by email — never returned in the response. return json({ ok:true }, 200, req, env); } // --- verify the code --- if(path==='/api/auth/verify-code' && req.method==='POST'){ const { dest, code } = await req.json(); if(!reEmail.test(dest||'') || !/^\d{6}$/.test(code||'')) return json({ ok:false }, 400, req, env); const d = dest.toLowerCase(); const row = await env.DB.prepare(`SELECT * FROM otp_codes WHERE dest=? AND consumed=0 ORDER BY id DESC LIMIT 1`).bind(d).first(); if(!row || row.expires_at < Date.now()) return json({ ok:false, error:'expired' }, 200, req, env); if(row.attempts >= 5) return json({ ok:false, error:'too_many_attempts' }, 200, req, env); if((await sha256hex(code + PEPPER)) !== row.code_hash){ await env.DB.prepare(`UPDATE otp_codes SET attempts=attempts+1 WHERE id=?`).bind(row.id).run(); return json({ ok:false, error:'incorrect' }, 200, req, env); } await env.DB.prepare(`UPDATE otp_codes SET consumed=1 WHERE id=?`).bind(row.id).run(); await env.DB.prepare(`INSERT INTO verified_emails(dest, verified_at) VALUES(?,?) ON CONFLICT(dest) DO UPDATE SET verified_at=excluded.verified_at`).bind(d, Date.now()).run(); return json({ ok:true }, 200, req, env); } // --- create the account (after email verified) --- // --- availability check (lets signup warn early, before sending an OTP) --- if(path==='/api/auth/check' && req.method==='GET'){ const email = (url.searchParams.get('email')||'').toLowerCase().trim(); const username = (url.searchParams.get('username')||'').trim(); let emailTaken = false, usernameTaken = false; if(email){ const e = await env.DB.prepare(`SELECT 1 FROM users WHERE email=?`).bind(email).first(); emailTaken = !!e; } if(username){ const u = await env.DB.prepare(`SELECT 1 FROM users WHERE username=?`).bind(username).first(); usernameTaken = !!u; } return json({ ok:true, emailTaken, usernameTaken }, 200, req, env); } if(path==='/api/auth/register' && req.method==='POST'){ const body = await req.json(); const { email, username, password, full_name, dob, phone } = body; if(!reEmail.test(email||'')) return json({ ok:false, error:'invalid_email' }, 400, req, env); if(!reUser.test(username||'')) return json({ ok:false, error:'invalid_username' }, 400, req, env); if(!validPassword(password)) return json({ ok:false, error:'weak_password' }, 400, req, env); const e = email.toLowerCase(); const v = await env.DB.prepare(`SELECT verified_at FROM verified_emails WHERE dest=?`).bind(e).first(); if(!v || v.verified_at < Date.now()-30*60*1000) return json({ ok:false, error:'email_not_verified' }, 400, req, env); const id = crypto.randomUUID(); const pwHash = await hashPassword(password); try{ await env.DB.prepare(`INSERT INTO users(id,email,username,password_hash,full_name,dob,phone,email_verified,tier) VALUES(?,?,?,?,?,?,?,1,'none')`).bind(id, e, username, pwHash, full_name||null, dob||null, phone||null).run(); }catch(err){ if(String(err).toUpperCase().includes('UNIQUE')) return json({ ok:false, error:'taken' }, 409, req, env); throw err; } // Referral: give this account its own code, and link the referrer if a valid code was used. try{ let referrer = null; const used = (body.ref||'').toString().trim().toUpperCase(); if(used){ const r = await env.DB.prepare(`SELECT id FROM users WHERE ref_code=?`).bind(used).first(); if(r && r.id!==id) referrer = used; } await env.DB.prepare(`UPDATE users SET ref_code=?, referred_by=? WHERE id=?`).bind(genRefCode(), referrer, id).run(); }catch(e2){ /* referral schema not applied yet — safe to ignore */ } await env.DB.prepare(`DELETE FROM verified_emails WHERE dest=?`).bind(e).run(); const token = await signSession(id, JWT); return json({ ok:true, token, user:{ id, email:e, username, tier:'none', role:'member' } }, 200, req, env, { 'Set-Cookie': sessionCookie(token, 7*24*3600) }); } // --- login --- if(path==='/api/auth/login' && req.method==='POST'){ const { email, password } = await req.json(); if(!reEmail.test(email||'') || !password) return json({ ok:false }, 400, req, env); const u = await env.DB.prepare(`SELECT id,password_hash,username,tier,role FROM users WHERE email=?`).bind(email.toLowerCase()).first(); if(!u || !(await verifyPassword(password, u.password_hash))) return json({ ok:false, error:'invalid_credentials' }, 401, req, env); const token = await signSession(u.id, JWT); const effTier = (u.role==='admin' && (!u.tier || u.tier==='none')) ? 'vip' : u.tier; // admins are auto-VIP return json({ ok:true, token, user:{ id:u.id, username:u.username, tier:effTier, role:u.role } }, 200, req, env, { 'Set-Cookie': sessionCookie(token, 7*24*3600) }); } // --- logout --- if(path==='/api/auth/logout' && req.method==='POST') return json({ ok:true }, 200, req, env, { 'Set-Cookie': sessionCookie('', 0) }); // --- who am I --- if(path==='/api/auth/me' && req.method==='GET'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); try{ await env.DB.prepare(`UPDATE users SET last_seen=? WHERE id=?`).bind(Date.now(), sess.uid).run(); }catch(eSeen){} // online heartbeat const u = await env.DB.prepare(`SELECT id,email,username,full_name,tier,role,twofa_enabled FROM users WHERE id=?`).bind(sess.uid).first(); if(u && u.role==='admin' && (!u.tier || u.tier==='none')) u.tier='vip'; // admins are auto-VIP let extra = {}; try{ const r = await env.DB.prepare(`SELECT ref_code,ref_balance,payout_count FROM users WHERE id=?`).bind(sess.uid).first(); if(r) extra = r; }catch(e3){} try{ const t = await env.DB.prepare(`SELECT 1 FROM tg_links WHERE user_id=? LIMIT 1`).bind(sess.uid).first(); extra.telegram_linked = !!t; }catch(e4){} return json({ ok:true, user: u ? { ...u, ...extra } : null }, 200, req, env); } // ============ REFERRALS ============ if(path==='/api/referral/me' && req.method==='GET'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); try{ let row = await env.DB.prepare(`SELECT ref_code, COALESCE(ref_balance,0) AS ref_balance, COALESCE(payout_count,0) AS payout_count FROM users WHERE id=?`).bind(sess.uid).first(); if(row && !row.ref_code){ const code=genRefCode(); await env.DB.prepare(`UPDATE users SET ref_code=? WHERE id=?`).bind(code, sess.uid).run(); row.ref_code=code; } const referred = await env.DB.prepare(`SELECT COUNT(*) AS n FROM users WHERE referred_by=?`).bind(row.ref_code).first(); const earns = await env.DB.prepare(`SELECT amount, created_at FROM referral_earnings WHERE referrer_id=? ORDER BY created_at DESC LIMIT 10`).bind(sess.uid).all(); const pend = await env.DB.prepare(`SELECT id, amount, status, created_at FROM payout_requests WHERE user_id=? AND status='pending' ORDER BY created_at DESC LIMIT 1`).bind(sess.uid).first(); return json({ ok:true, ref_code:row.ref_code, balance:row.ref_balance, payout_count:row.payout_count, referred:(referred&&referred.n)||0, require_proof: row.payout_count < 5, min:15, pending: pend||null, earnings:(earns&&earns.results)||[] }, 200, req, env); }catch(e){ return json({ ok:false, error:'referral_unavailable' }, 200, req, env); } } if(path==='/api/referral/payout' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); try{ const form = await req.formData(); const addr = (form.get('ltc_address')||'').toString().trim(); if(addr.length < 20) return json({ ok:false, error:'bad_address' }, 400, req, env); const row = await env.DB.prepare(`SELECT username,email,COALESCE(ref_balance,0) AS bal, COALESCE(payout_count,0) AS pc FROM users WHERE id=?`).bind(sess.uid).first(); const MIN = 15; if(!row || row.bal < MIN) return json({ ok:false, error:'below_min', min:MIN }, 400, req, env); const existing = await env.DB.prepare(`SELECT id FROM payout_requests WHERE user_id=? AND status='pending'`).bind(sess.uid).first(); if(existing) return json({ ok:false, error:'pending_exists' }, 409, req, env); const requireProof = row.pc < 5; const keys=[]; for(const f of form.getAll('proof')){ if(f && typeof f!=='string' && f.size){ try{ const k=await saveUpload(env,f); if(k) keys.push(k); }catch(e){} } } if(requireProof && keys.length===0) return json({ ok:false, error:'proof_required' }, 400, req, env); const id=crypto.randomUUID(); await env.DB.prepare(`INSERT INTO payout_requests(id,user_id,username,email,amount,ltc_address,proof_keys,require_proof,status,created_at) VALUES(?,?,?,?,?,?,?,?,'pending',?)`) .bind(id, sess.uid, row.username||null, row.email||null, row.bal, addr, keys.join(','), requireProof?1:0, Date.now()).run(); return json({ ok:true, id, amount: row.bal }, 200, req, env); }catch(e){ return json({ ok:false, error:'referral_unavailable' }, 500, req, env); } } // ============ STAGE 2: predictions, slips, results, notifications ============ // Serve an uploaded image from R2 (public). if(path.startsWith('/api/img/') && req.method==='GET'){ const key = decodeURIComponent(path.slice('/api/img/'.length)); const obj = await env.BUCKET.get(key); if(!obj) return json({ ok:false, error:'not_found' }, 404, req, env); const headers = new Headers(corsHeaders(req, env)); obj.writeHttpMetadata(headers); headers.set('Cache-Control', 'public, max-age=86400'); return new Response(obj.body, { headers }); } // Real sports news — publisher RSS feeds (BBC Sport, Sky Sports). // We show headline + source + link only, and link back to the publisher. // We never republish article text — that stays the publisher's copyright. if(path==='/api/news/public' && req.method==='GET'){ const FEEDS=[ { src:'BBC Sport', url:'https://feeds.bbci.co.uk/sport/football/rss.xml' }, { src:'Sky Sports', url:'https://www.skysports.com/rss/12040' }, ]; const items=[]; for(const f of FEEDS){ try{ const r=await fetch(f.url,{ cf:{ cacheTtl:1800 } }); if(!r.ok) continue; const xml=await r.text(); const parts=xml.split('').slice(1,11); for(const p of parts){ const grab=(tag)=>{ const m=p.match(new RegExp('<'+tag+'>([\\s\\S]*?)')); if(!m) return ''; return m[1].replace(//g,'').trim(); }; const title=grab('title'), link=grab('link'), pub=grab('pubDate'); if(title&&link) items.push({ title:title.slice(0,160), link, source:f.src, published:pub||null, ts:Date.parse(pub||'')||0 }); } }catch(e){} } items.sort((a,b)=>b.ts-a.ts); return json({ ok:true, news: items.slice(0,20) }, 200, req, env, { 'Cache-Control':'public, max-age=1800' }); } // Public scores: today's fixtures with live/final scores (from our D1, refreshed by cron/admin). if(path==='/api/scores/public' && req.method==='GET'){ try{ const sport=(url.searchParams.get('sport')||'football').toLowerCase(); const from=Date.now()-30*3600000, to=Date.now()+30*3600000; const rows=await env.DB.prepare(`SELECT sport,league,home,away,start_ts,status,score_home,score_away FROM fixtures WHERE sport=? AND start_ts BETWEEN ? AND ? ORDER BY start_ts ASC LIMIT 60`).bind(sport,from,to).all(); return json({ ok:true, scores:(rows&&rows.results)||[] }, 200, req, env, { 'Cache-Control':'public, max-age=120' }); }catch(e){ return json({ ok:true, scores:[] }, 200, req, env); } } // ---- PUBLIC READS (what every user's app fetches) ---- // PUBLIC: this week's games (served from D1 cache — zero API-Sports quota per visitor) if(path==='/api/fixtures' && req.method==='GET'){ const sport=(url.searchParams.get('sport')||'football').toLowerCase(); try{ const rows = await env.DB.prepare(`SELECT league,home,away,start_ts,odds FROM fixtures WHERE sport=? AND start_ts > ? ORDER BY start_ts ASC LIMIT 120`).bind(sport, Date.now()-3600000).all(); return json({ ok:true, fixtures:(rows&&rows.results)||[] }, 200, req, env, { 'Cache-Control':'public, max-age=300' }); }catch(e){ return json({ ok:true, fixtures:[] }, 200, req, env); } } if(path==='/api/predictions' && req.method==='GET'){ const { results } = await env.DB.prepare(`SELECT id,sport,match,pick,odds,risk,status,created_at,settled_at FROM predictions ORDER BY created_at DESC LIMIT 50`).all(); return json({ ok:true, predictions: results||[] }, 200, req, env); } if(path==='/api/codes' && req.method==='GET'){ const { results } = await env.DB.prepare(`SELECT id,title,sport,odds,games,risk,sure,tier,selections FROM codes WHERE active=1 ORDER BY sort_order ASC, rowid ASC LIMIT 60`).all(); // Entitlement check — the actual code strings are the paid product. // Anonymous callers and free users get the list WITHOUT code values. let entitledAll = false; const owned = new Set(); const sess = await readSession(tokenFrom(req), JWT); if(sess){ const u = await env.DB.prepare(`SELECT tier, role FROM users WHERE id=?`).bind(sess.uid).first(); entitledAll = !!(u && u.role==='admin'); // subscribers now unlock per-code via admin approval (/api/codes/unlock) if(!entitledAll){ try{ const ps = await env.DB.prepare(`SELECT code_id FROM purchases WHERE user_id=? AND status='paid' AND code_id IS NOT NULL`).bind(sess.uid).all(); for(const r of ((ps&&ps.results)||[])) owned.add(Number(r.code_id)); }catch(e){} } } const parse = s => { try{ return JSON.parse(s||'[]'); }catch(e){ return []; } }; const codes = (results||[]).map(c => (entitledAll || owned.has(Number(c.id))) ? { ...c, selections: parse(c.selections) } : { ...c, selections: null }); // selections are the paid product — released only via approved unlock return json({ ok:true, codes }, 200, req, env); } if(path==='/api/slips' && req.method==='GET'){ const { results } = await env.DB.prepare(`SELECT id,title,book,odds,image_key,status,created_at,settled_at FROM slips ORDER BY created_at DESC LIMIT 50`).all(); const slips = (results||[]).map(s=>({ ...s, image_url: imgUrl(env, s.image_key), image_key: undefined })); return json({ ok:true, slips }, 200, req, env); } if(path==='/api/notifications' && req.method==='GET'){ const { results } = await env.DB.prepare(`SELECT id,title,body,image_key,created_at FROM notifications ORDER BY created_at DESC LIMIT 50`).all(); const notifications = (results||[]).map(n=>({ ...n, image_url: imgUrl(env, n.image_key), image_key: undefined })); return json({ ok:true, notifications }, 200, req, env); } // Reviews — public list + aggregate (honest: only real, user-submitted reviews) if(path==='/api/reviews' && req.method==='GET'){ try{ const agg = await env.DB.prepare(`SELECT COUNT(*) c, AVG(rating) a FROM reviews WHERE status='visible'`).first(); const { results } = await env.DB.prepare(`SELECT id,username,rating,body,created_at FROM reviews WHERE status='visible' ORDER BY created_at DESC LIMIT 20`).all(); const count = (agg&&agg.c)||0; const avg = count ? Math.round((agg.a)*10)/10 : 0; return json({ ok:true, count, avg, items: results||[] }, 200, req, env); }catch(e){ return json({ ok:true, count:0, avg:0, items:[] }, 200, req, env); } } // Submit a review — must be signed in. Rating 1–5, optional short text. if(path==='/api/reviews' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const { rating, body } = await req.json(); const rt = Number(rating); if(!(rt>=1 && rt<=5)) return json({ ok:false, error:'bad_rating' }, 400, req, env); const text = (body||'').toString().trim().slice(0,500); const u = await env.DB.prepare(`SELECT username FROM users WHERE id=?`).bind(sess.uid).first(); const uname = (u&&u.username) ? u.username : 'Member'; await env.DB.prepare(`INSERT INTO reviews(user_id,username,rating,body,status) VALUES(?,?,?,?,'visible') ON CONFLICT(user_id) DO UPDATE SET rating=excluded.rating, body=excluded.body, username=excluded.username, status='visible', created_at=datetime('now')`) .bind(sess.uid, uname, rt, text).run(); return json({ ok:true }, 200, req, env); } // ---- ADMIN WRITES (must be a signed-in admin) ---- if(path.startsWith('/api/admin/')){ const adminId = await requireAdmin(req, env, JWT); if(!adminId) return json({ ok:false, error:'forbidden' }, 403, req, env); // Email diagnostics: try sending a real email via Resend and return the exact result. if(path==='/api/admin/email-test' && req.method==='POST'){ const b = await req.json().catch(()=>({})); const to = (b.to||'').toString().trim(); if(!reEmail.test(to)) return json({ ok:false, detail:'Enter a valid email address.' }, 400, req, env); if(!env.RESEND_API_KEY) return json({ ok:false, status:0, detail:'RESEND_API_KEY is not set on this Worker. Add it in Settings → Variables, then redeploy.' }, 200, req, env); try{ const r = await fetch('https://api.resend.com/emails', { method:'POST', headers:{ 'Authorization':`Bearer ${env.RESEND_API_KEY}`, 'Content-Type':'application/json' }, body: JSON.stringify({ from: env.MAIL_FROM || 'Betstack Pro ', to:[to], reply_to: env.REPLY_TO || 'support@betstackpro.com', subject:'Betstack Pro — email test', html: otpEmailHtml('123456'), text:'This is a Betstack Pro test email. If you received it, sending works.' }) }); const detail = await r.text(); return json({ ok:r.ok, status:r.status, from: env.MAIL_FROM || 'Betstack Pro ', detail }, 200, req, env); }catch(e){ return json({ ok:false, status:0, detail:'Network error contacting Resend: '+String(e) }, 200, req, env); } } // Fixtures browser: cached daily games per sport (admin only) if(path==='/api/admin/fixtures' && req.method==='GET'){ const sport=(url.searchParams.get('sport')||'football').toLowerCase(); // Serve fixtures saved by the football-data refresh (the live source). try{ const rows=await env.DB.prepare(`SELECT id,sport,league,home,away,start_ts,status,score_home,score_away FROM fixtures WHERE sport=? AND start_ts > ? ORDER BY start_ts ASC LIMIT 250`).bind(sport, Date.now()-6*3600000).all(); const fixtures=(rows&&rows.results)||[]; if(fixtures.length || sport==='football') return json({ ok:true, fixtures }, 200, req, env); }catch(e){} const date=(url.searchParams.get('date')||new Date().toISOString().slice(0,10)).slice(0,10); if(!SPORT_HOSTS[sport]) return json({ ok:false, error:'bad_sport' }, 400, req, env); if(!env.SPORTS_API_KEY) return json({ ok:false, error:'no_api_key', note:'Add SPORTS_API_KEY secret in Worker settings.' }, 200, req, env); try{ const hit = await env.DB.prepare(`SELECT payload,fetched_at FROM fixtures_cache WHERE sport=? AND day=?`).bind(sport,date).first(); if(hit && (Date.now()-hit.fetched_at) < 12*3600*1000) return json({ ok:true, cached:true, fixtures: JSON.parse(hit.payload) }, 200, req, env); }catch(e){} const ep = sport==='football' ? `/fixtures?date=${date}` : `/games?date=${date}`; const data = await fetchSport(env, sport, ep); if(!data) return json({ ok:false, error:'fetch_failed' }, 502, req, env); const fixtures = mapFixtures(sport, data); try{ await env.DB.prepare(`INSERT INTO fixtures_cache(sport,day,payload,fetched_at) VALUES(?,?,?,?) ON CONFLICT(sport,day) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at`) .bind(sport,date,JSON.stringify(fixtures),Date.now()).run(); }catch(e){} return json({ ok:true, fixtures }, 200, req, env); } // Refresh fixtures from football-data.org (current season) into the fixtures table. if(path==='/api/admin/fixtures/refresh' && req.method==='POST'){ const b = await req.json().catch(()=>({})); const sport=(b.sport||'football').toLowerCase(); try{ let r; if(sport==='football'){ r = await refreshFootballFD(env); } else { r = await refreshOddsSport(env, sport); } // basketball/nfl/mlb/nhl/tennis/mma via the-odds-api return json(r, 200, req, env); }catch(e){ return json({ ok:false, error:'refresh_failed', detail:String(e).slice(0,200) }, 200, req, env); } } // Fixture detail: odds + H2H (football only; cached 6h; ~2 API calls per tap) if(path==='/api/admin/fixture-detail' && req.method==='GET'){ const fid=url.searchParams.get('id')||''; const h2h=url.searchParams.get('h2h')||''; if(!fid) return json({ ok:false, error:'id_required' }, 400, req, env); try{ const hit = await env.DB.prepare(`SELECT payload,fetched_at FROM fixture_detail_cache WHERE fixture_id=?`).bind(fid).first(); if(hit && (Date.now()-hit.fetched_at) < 6*3600*1000) return json({ ok:true, cached:true, ...JSON.parse(hit.payload) }, 200, req, env); }catch(e){} const out={ odds:null, h2h:[] }; const od = await fetchSport(env,'football',`/odds?fixture=${fid}`); try{ const bets=(od&&od.response&&od.response[0]&&od.response[0].bookmakers&&od.response[0].bookmakers[0]&&od.response[0].bookmakers[0].bets)||[]; const mk={}; for(const b of bets){ if(['Match Winner','Goals Over/Under','Both Teams Score'].includes(b.name)) mk[b.name]=b.values.slice(0,6); } out.odds=mk; }catch(e){} if(h2h){ const hh = await fetchSport(env,'football',`/fixtures/headtohead?h2h=${h2h}&last=5`); try{ out.h2h=((hh&&hh.response)||[]).map(f=>({date:(f.fixture.date||'').slice(0,10),home:f.teams.home.name,away:f.teams.away.name,score:`${f.goals.home??'-'}:${f.goals.away??'-'}`})); }catch(e){} } try{ await env.DB.prepare(`INSERT INTO fixture_detail_cache(fixture_id,payload,fetched_at) VALUES(?,?,?) ON CONFLICT(fixture_id) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at`) .bind(fid,JSON.stringify(out),Date.now()).run(); }catch(e){} return json({ ok:true, ...out }, 200, req, env); } // Users directory with filters, search, online + partner flags if(path==='/api/admin/users' && req.method==='GET'){ const f=(url.searchParams.get('filter')||'all'); const q=(url.searchParams.get('q')||'').trim().toLowerCase(); try{ const rows = await env.DB.prepare(`SELECT id,username,email,full_name,phone,tier,role,ref_code, COALESCE(ref_balance,0) ref_balance, COALESCE(payout_count,0) payout_count, last_seen, (SELECT COUNT(*) FROM users r WHERE r.referred_by=users.ref_code) referred FROM users ORDER BY COALESCE(last_seen,0) DESC LIMIT 500`).all(); const now=Date.now(); let list=((rows&&rows.results)||[]).map(u=>({ ...u, online: !!(u.last_seen && now-u.last_seen<15*60*1000), partner: (u.referred>0 || u.ref_balance>0), member: !!(u.tier&&u.tier!=='none') })); if(f==='online') list=list.filter(u=>u.online); if(f==='members') list=list.filter(u=>u.member); if(f==='partners') list=list.filter(u=>u.partner); if(f==='admins') list=list.filter(u=>u.role==='admin'); if(q) list=list.filter(u=>(u.username||'').toLowerCase().includes(q)||(u.email||'').toLowerCase().includes(q)||(u.full_name||'').toLowerCase().includes(q)); return json({ ok:true, users:list.slice(0,200), total:list.length }, 200, req, env); }catch(e){ return json({ ok:true, users:[], total:0 }, 200, req, env); } } // Recent activity feed: signups, paid purchases, unlocks, payout requests if(path==='/api/admin/activity' && req.method==='GET'){ const acts=[]; try{ const r=await env.DB.prepare(`SELECT p.created_at ts, u.username, p.code_title item, p.amount FROM purchases p LEFT JOIN users u ON u.id=p.user_id WHERE p.status='paid' ORDER BY p.rowid DESC LIMIT 15`).all(); for(const x of ((r&&r.results)||[])) acts.push({t:'payment', ts:x.ts, text:`${x.username||'user'} paid $${x.amount} — ${x.item||''}`}); }catch(e){} try{ const r=await env.DB.prepare(`SELECT ur.created_at ts, ur.status, u.username, c.title FROM unlock_requests ur LEFT JOIN users u ON u.id=ur.user_id LEFT JOIN codes c ON c.id=ur.code_id ORDER BY ur.created_at DESC LIMIT 15`).all(); for(const x of ((r&&r.results)||[])) acts.push({t:'unlock', ts:x.ts, text:`${x.username||'user'} requested "${x.title||'tip'}" (${x.status})`}); }catch(e){} try{ const r=await env.DB.prepare(`SELECT created_at ts, username, amount, status FROM payout_requests ORDER BY created_at DESC LIMIT 10`).all(); for(const x of ((r&&r.results)||[])) acts.push({t:'payout', ts:x.ts, text:`${x.username||'user'} payout $${x.amount} (${x.status})`}); }catch(e){} acts.sort((a,b)=>(b.ts||0)-(a.ts||0)); return json({ ok:true, activity:acts.slice(0,30) }, 200, req, env); } // Unlock requests: pending queue + recent decisions if(path==='/api/admin/unlocks' && req.method==='GET'){ try{ const rows = await env.DB.prepare(`SELECT ur.id, ur.status, ur.created_at, ur.decided_at, u.username, u.tier, c.title, c.games, c.odds FROM unlock_requests ur LEFT JOIN users u ON u.id=ur.user_id LEFT JOIN codes c ON c.id=ur.code_id ORDER BY (ur.status='pending') DESC, ur.created_at DESC LIMIT 60`).all(); return json({ ok:true, unlocks:(rows&&rows.results)||[] }, 200, req, env); }catch(e){ return json({ ok:true, unlocks:[] }, 200, req, env); } } if(path==='/api/admin/unlock/decide' && req.method==='POST'){ const b = await req.json().catch(()=>({})); const r = await decideUnlock(env, b.id, b.action==='approved'?'approved':'rejected'); return json(r, r.ok?200:409, req, env); } // Live stats: online users (seen <15 min), totals, per-tier subscriber counts, pending unlocks if(path==='/api/admin/stats' && req.method==='GET'){ const out = { ok:true, online:0, total_users:0, tiers:{}, pending_unlocks:0 }; try{ const t = await env.DB.prepare(`SELECT COUNT(*) n FROM users`).first(); out.total_users=(t&&t.n)||0; }catch(e){} try{ const o = await env.DB.prepare(`SELECT COUNT(*) n FROM users WHERE last_seen > ?`).bind(Date.now()-15*60*1000).first(); out.online=(o&&o.n)||0; }catch(e){} try{ const rows = await env.DB.prepare(`SELECT tier, COUNT(*) n FROM users WHERE tier IS NOT NULL AND tier!='none' GROUP BY tier`).all(); for(const r of ((rows&&rows.results)||[])) out.tiers[r.tier]=r.n; }catch(e){} try{ const p = await env.DB.prepare(`SELECT COUNT(*) n FROM unlock_requests WHERE status='pending'`).first(); out.pending_unlocks=(p&&p.n)||0; }catch(e){} return json(out, 200, req, env); } // Referral payouts: list requests (pending first) with proof image URLs if(path==='/api/admin/payouts' && req.method==='GET'){ try{ const rows = await env.DB.prepare(`SELECT * FROM payout_requests ORDER BY (status='pending') DESC, created_at DESC LIMIT 100`).all(); const list = ((rows&&rows.results)||[]).map(r=>({ id:r.id, username:r.username, email:r.email, amount:r.amount, ltc_address:r.ltc_address, require_proof:r.require_proof, status:r.status, admin_note:r.admin_note, created_at:r.created_at, proofs:(r.proof_keys||'').split(',').filter(Boolean).map(k=>imgUrl(env,k)) })); return json({ ok:true, payouts:list }, 200, req, env); }catch(e){ return json({ ok:true, payouts:[] }, 200, req, env); } } // Approve (mark paid) or reject a payout. Paid deducts balance and counts a completed session. if(path==='/api/admin/payout/decide' && req.method==='POST'){ const b = await req.json(); const r = await env.DB.prepare(`SELECT * FROM payout_requests WHERE id=?`).bind(b.id).first(); if(!r) return json({ ok:false, error:'not_found' }, 404, req, env); if(r.status!=='pending') return json({ ok:false, error:'already_decided' }, 409, req, env); if(b.action==='paid'){ await env.DB.prepare(`UPDATE users SET ref_balance = MAX(0, COALESCE(ref_balance,0)-?), payout_count = COALESCE(payout_count,0)+1 WHERE id=?`).bind(r.amount, r.user_id).run(); await env.DB.prepare(`UPDATE payout_requests SET status='paid', admin_note=?, decided_at=? WHERE id=?`).bind(b.note||null, Date.now(), b.id).run(); // Partner tier auto-promotion after a clean payout: // starter (10%) -> trusted (15%): 5 completed payouts AND >=10 referred users // trusted (15%) -> elite (20%): >=25 referred users try{ const pu = await env.DB.prepare(`SELECT ref_code, COALESCE(payout_count,0) pc, COALESCE(ref_rate_bps,1000) rate FROM users WHERE id=?`).bind(r.user_id).first(); if(pu && pu.ref_code){ const rc = await env.DB.prepare(`SELECT COUNT(*) n FROM users WHERE referred_by=?`).bind(pu.ref_code).first(); const n = (rc&&rc.n)||0; let newRate = pu.rate; if(pu.rate < 1500 && pu.pc >= 5 && n >= 10) newRate = 1500; if(newRate >= 1500 && n >= 25) newRate = 2000; if(newRate !== pu.rate) await env.DB.prepare(`UPDATE users SET ref_rate_bps=? WHERE id=?`).bind(newRate, r.user_id).run(); } }catch(ePromo){ /* ref_rate_bps column not added yet — promotion silently skipped */ } }else{ await env.DB.prepare(`UPDATE payout_requests SET status='rejected', admin_note=?, decided_at=? WHERE id=?`).bind(b.note||'Rejected', Date.now(), b.id).run(); } return json({ ok:true }, 200, req, env); } // Create MANY free predictions at once — one per selected game (from the Games browser). if(path==='/api/admin/predictions/bulk' && req.method==='POST'){ const b=await req.json().catch(()=>({})); const sels=Array.isArray(b.selections)?b.selections.slice(0,20):[]; if(!sels.length) return json({ ok:false, error:'no_selections' }, 400, req, env); let n=0; for(const s of sels){ if(!s.match||!s.pick) continue; const id=crypto.randomUUID(); await env.DB.prepare(`INSERT INTO predictions(id,sport,match,pick,odds,risk,status) VALUES(?,?,?,?,?,?,'pending')`) .bind(id, b.sport||s.sport||'Football', String(s.match).slice(0,120), String(s.pick).slice(0,80), Number(s.odds)||null, b.risk||'Medium').run(); n++; await tgGroup(env, `\uD83C\uDFAF New free prediction\n${s.match}\n${s.pick}${s.odds?` @ ${s.odds}`:''}\n\nOutcomes are never guaranteed \u2014 18+, bet responsibly.`); } return json({ ok:true, created:n }, 200, req, env); } // Create a prediction (free tip) if(path==='/api/admin/prediction' && req.method==='POST'){ const { sport, match, pick, odds, risk } = await req.json(); if(!match || !pick) return json({ ok:false, error:'missing_fields' }, 400, req, env); const id = crypto.randomUUID(); await env.DB.prepare(`INSERT INTO predictions(id,sport,match,pick,odds,risk,status) VALUES(?,?,?,?,?,?,'pending')`) .bind(id, sport||null, match, pick, Number(odds)||null, risk||'Medium').run(); await tgGroup(env, `🎯 New free prediction\n${match}\n${pick}${odds?` @ ${odds}`:''}${risk?` · ${risk} risk`:''}\n\nFull bet tips for subscribers → betstackpro.com`); return json({ ok:true, id }, 200, req, env); } // Edit a prediction's text/fields (does not change win/loss status) if(path==='/api/admin/prediction/edit' && req.method==='POST'){ const { id, sport, match, pick, odds, risk } = await req.json(); if(!id || !match || !pick) return json({ ok:false, error:'missing_fields' }, 400, req, env); await env.DB.prepare(`UPDATE predictions SET sport=?, match=?, pick=?, odds=?, risk=? WHERE id=?`) .bind(sport||null, match, pick, Number(odds)||null, risk||'Medium', id).run(); return json({ ok:true }, 200, req, env); } // Create or edit a booking code (tip). If id is provided, it's an edit. if(path==='/api/admin/code' && req.method==='POST'){ const b = await req.json(); // Bet tips are now SELECTIONS (matches + picks + odds) — bookmaker-neutral, no booking codes. const sels = Array.isArray(b.selections) ? b.selections .map(s=>({ match:(s.match||'').toString().trim().slice(0,80), pick:(s.pick||'').toString().trim().slice(0,60), odds:Number(s.odds)||0, date:(s.date||'').toString().slice(0,10), time:(s.time||'').toString().slice(0,5) })) .filter(s=>s.match && s.pick && s.odds>0) .slice(0,30) : []; if(!b.title || !sels.length) return json({ ok:false, error:'missing_fields' }, 400, req, env); const sure = b.sure ? 1 : 0; const games = sels.length; const odds = +(sels.reduce((t,s)=>t*s.odds,1)).toFixed(2); const tier = ['bronze','silver','gold','vip'].includes(b.tier) ? b.tier : null; const selJson = JSON.stringify(sels); if(b.id){ await env.DB.prepare(`UPDATE codes SET title=?,sport=?,book=NULL,odds=?,games=?,risk=?,code='',sure=?,selections=?,tier=? WHERE id=?`) .bind(b.title, b.sport||'Football', odds, games, b.risk||'Medium', sure, selJson, tier, b.id).run(); return json({ ok:true, id:b.id }, 200, req, env); } const id = crypto.randomUUID(); const ord = Number(b.sort_order)||Date.now(); await env.DB.prepare(`INSERT INTO codes(id,title,sport,book,odds,games,risk,code,sure,active,sort_order,selections,tier) VALUES(?,?,?,NULL,?,?,?,'',?,1,?,?,?)`) .bind(id, b.title, b.sport||'Football', odds, games, b.risk||'Medium', sure, ord, selJson, tier).run(); await tgGroup(env, `\uD83C\uDFAB New premium tip posted\n${b.title} \u2014 ${games} game${games>1?'s':''} \u00B7 combined @ ${odds}\nSubscribers can unlock it in the app now.`, betBtn(env)); return json({ ok:true, id }, 200, req, env); } // Settle a prediction or slip: { kind:'prediction'|'slip', id, status:'won'|'lost'|'pending' } if(path==='/api/admin/settle' && req.method==='POST'){ const { kind, id, status } = await req.json(); if(!['won','lost','pending'].includes(status)) return json({ ok:false, error:'bad_status' }, 400, req, env); const table = kind==='slip' ? 'slips' : 'predictions'; await env.DB.prepare(`UPDATE ${table} SET status=?, settled_at=CASE WHEN ?='pending' THEN NULL ELSE CURRENT_TIMESTAMP END WHERE id=?`).bind(status, status, id).run(); if(status==='won' || status==='lost'){ try{ let line = ''; if(table==='predictions'){ const p = await env.DB.prepare(`SELECT match,pick,odds FROM predictions WHERE id=?`).bind(id).first(); if(p) line = `${p.match}\n${p.pick}${p.odds?` @ ${p.odds}`:''}`; }else{ const s = await env.DB.prepare(`SELECT title,book,odds FROM slips WHERE id=?`).bind(id).first(); if(s) line = `${s.title}${s.book?` (${s.book})`:''}${s.odds?` @ ${s.odds}`:''}`; } await tgGroup(env, status==='won' ? `✅ WON\n${line}\n\nOn the record — see all verified results at betstackpro.com` : `❌ LOST\n${line}\n\nWe post losses too. Full record at betstackpro.com`, betBtn(env)); }catch(eG){} } return json({ ok:true }, 200, req, env); } // Post a booking slip with an image (multipart: file, title, book, odds) if(path==='/api/admin/slip' && req.method==='POST'){ const form = await req.formData(); let key; try{ key = await saveUpload(env, form.get('file')); } catch{ return json({ ok:false, error:'file_too_large' }, 400, req, env); } const id = crypto.randomUUID(); await env.DB.prepare(`INSERT INTO slips(id,title,book,odds,image_key,status) VALUES(?,?,?,?,?,'pending')`) .bind(id, form.get('title')||'Booking slip', form.get('book')||null, Number(form.get('odds'))||null, key).run(); await tgGroup(env, `🎫 New slip posted\n${form.get('title')||'Booking slip'}${form.get('book')?` (${form.get('book')})`:''}${form.get('odds')?` @ ${form.get('odds')}`:''}\n\nView it at betstackpro.com`); return json({ ok:true, id, image_url: imgUrl(env, key) }, 200, req, env); } // Edit a booking slip (multipart: id, title, book, odds, optional file to replace image) if(path==='/api/admin/slip/edit' && req.method==='POST'){ const form = await req.formData(); const id = form.get('id'); if(!id) return json({ ok:false, error:'missing_id' }, 400, req, env); let key; try{ key = await saveUpload(env, form.get('file')); } catch{ return json({ ok:false, error:'file_too_large' }, 400, req, env); } if(key){ await env.DB.prepare(`UPDATE slips SET title=?,book=?,odds=?,image_key=? WHERE id=?`) .bind(form.get('title')||'Booking slip', form.get('book')||null, Number(form.get('odds'))||null, key, id).run(); }else{ await env.DB.prepare(`UPDATE slips SET title=?,book=?,odds=? WHERE id=?`) .bind(form.get('title')||'Booking slip', form.get('book')||null, Number(form.get('odds'))||null, id).run(); } return json({ ok:true, id, image_url: imgUrl(env, key) }, 200, req, env); } if(path==='/api/admin/notification' && req.method==='POST'){ const form = await req.formData(); let key; try{ key = await saveUpload(env, form.get('file')); } catch{ return json({ ok:false, error:'file_too_large' }, 400, req, env); } const id = crypto.randomUUID(); await env.DB.prepare(`INSERT INTO notifications(id,title,body,image_key) VALUES(?,?,?,?)`) .bind(id, form.get('title')||'Update', form.get('body')||'', key).run(); return json({ ok:true, id, image_url: imgUrl(env, key) }, 200, req, env); } // Delete a prediction / slip / notification: { kind, id } if(path==='/api/admin/delete' && req.method==='POST'){ const { kind, id } = await req.json(); const table = { prediction:'predictions', slip:'slips', notification:'notifications', review:'reviews', code:'codes' }[kind]; if(!table) return json({ ok:false, error:'bad_kind' }, 400, req, env); await env.DB.prepare(`DELETE FROM ${table} WHERE id=?`).bind(id).run(); return json({ ok:true }, 200, req, env); } return json({ ok:false, error:'not_found' }, 404, req, env); } // ============ TELEGRAM (partner engine add-on) ============ // Tables used: tg_links, tg_link_tokens, tg_pending_refs (see migration). // Vars/secrets needed: BOT_TOKEN, BOT_USERNAME, TG_WEBHOOK_SECRET. // Relay an Arena chat message into the Telegram group. Signed-in users only. if(path==='/api/chat/relay' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const b = await req.json().catch(()=>({})); const text = (b.text||'').toString().trim().slice(0,300); if(!text) return json({ ok:false, error:'empty' }, 400, req, env); const u = await env.DB.prepare(`SELECT username FROM users WHERE id=?`).bind(sess.uid).first(); await tgGroup(env, `💬 ${(u&&u.username)?u.username:'Member'} (app): ${text.replace(/ Telegram group mirror. Auth required; 300 chars max. if(path==='/api/chat/send' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const b = await req.json().catch(()=>({})); const text = (b.text||'').toString().trim().slice(0,300); if(!text) return json({ ok:false, error:'empty' }, 400, req, env); const u = await env.DB.prepare(`SELECT username FROM users WHERE id=?`).bind(sess.uid).first(); await tgGroup(env, `\uD83D\uDCAC ${(u&&u.username)?u.username:'Member'} (app): ${text.replace(/({})); const codeId = (b.code_id||'').toString(); if(!codeId) return json({ ok:false, error:'code_id_required' }, 400, req, env); const c = await env.DB.prepare(`SELECT id,title,selections FROM codes WHERE id=? AND active=1`).bind(codeId).first(); if(!c) return json({ ok:false, error:'not_found' }, 404, req, env); const selsOf = row => { try{ return JSON.parse(row.selections||'[]'); }catch(e){ return []; } }; if(u.role==='admin') return json({ ok:true, status:'approved', selections:selsOf(c) }, 200, req, env); const existing = await env.DB.prepare(`SELECT id,status FROM unlock_requests WHERE user_id=? AND code_id=?`).bind(sess.uid, codeId).first(); if(existing){ if(existing.status==='approved') return json({ ok:true, status:'approved', selections:selsOf(c) }, 200, req, env); if(existing.status==='pending') return json({ ok:true, status:'pending' }, 200, req, env); await env.DB.prepare(`UPDATE unlock_requests SET status='pending', created_at=?, decided_at=NULL WHERE id=?`).bind(Date.now(), existing.id).run(); await dmAdmins(env, `\uD83D\uDD13 Unlock request (retry)\nUser: ${(u.username||'member')} (${u.tier})\nTip: ${c.title}\n\nConfirm the games are still open, then tap:`, existing.id); return json({ ok:true, status:'pending' }, 200, req, env); } const id = crypto.randomUUID(); await env.DB.prepare(`INSERT INTO unlock_requests(id,user_id,code_id,status,created_at) VALUES(?,?,?,'pending',?)`).bind(id, sess.uid, codeId, Date.now()).run(); await dmAdmins(env, `\uD83D\uDD13 Unlock request\nUser: ${(u.username||'member')} (${u.tier})\nTip: ${c.title}\n\nConfirm the games are still open, then tap:`, id); return json({ ok:true, status:'pending' }, 200, req, env); } if(path==='/api/codes/unlock-status' && req.method==='GET'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const codeId = url.searchParams.get('code_id')||''; const r = await env.DB.prepare(`SELECT status FROM unlock_requests WHERE user_id=? AND code_id=?`).bind(sess.uid, codeId).first(); if(!r) return json({ ok:true, status:'none' }, 200, req, env); if(r.status!=='approved') return json({ ok:true, status:r.status }, 200, req, env); const c = await env.DB.prepare(`SELECT selections FROM codes WHERE id=?`).bind(codeId).first(); let sels=[]; try{ sels=JSON.parse((c&&c.selections)||'[]'); }catch(e){} return json({ ok:true, status:'approved', selections:sels }, 200, req, env); } // Mint a one-time link token — SIGNED-IN, ACTIVE SUBSCRIBERS ONLY. // The front-end's "Connect my Telegram" button calls this. if(path==='/api/telegram/link-token' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const u = await env.DB.prepare(`SELECT tier, role FROM users WHERE id=?`).bind(sess.uid).first(); const isActive = u && ((u.tier && u.tier!=='none') || u.role==='admin'); if(!isActive) return json({ ok:false, error:'subscription_required' }, 403, req, env); const token = crypto.randomUUID().replace(/-/g,''); await env.DB.prepare(`INSERT INTO tg_link_tokens(token,user_id,expires_at) VALUES(?,?,?)`) .bind(token, sess.uid, Date.now()+10*60*1000).run(); return json({ ok:true, link_url:`https://t.me/${env.BOT_USERNAME}?start=link_${token}`, expires_in:600 }, 200, req, env); } // Telegram webhook — every bot update lands here. Header-gated, no cookie auth. if(path==='/api/telegram/webhook' && req.method==='POST'){ if((req.headers.get('X-Telegram-Bot-Api-Secret-Token')||'') !== (env.TG_WEBHOOK_SECRET||'')) return new Response('forbidden', { status:403 }); const update = await req.json().catch(()=>null); // One-tap Approve/Reject buttons from the admin DM if(update && update.callback_query){ const cq = update.callback_query; const data = (cq.data||''); const m = data.match(/^ul:(a|r):(.+)$/); const answer = async (text) => { try{ await fetch(`https://api.telegram.org/bot${(env.BOT_TOKEN||'').trim()}/answerCallbackQuery`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ callback_query_id: cq.id, text }) }); }catch(e){} }; if(m){ // Only linked ADMINS may press these const presser = await env.DB.prepare(`SELECT u.role FROM tg_links t JOIN users u ON u.id=t.user_id WHERE t.telegram_id=?`).bind(cq.from && cq.from.id).first(); if(!presser || presser.role!=='admin'){ await answer('Admins only.'); return json({ ok:true }, 200, req, env); } const r = await decideUnlock(env, m[2], m[1]==='a' ? 'approved' : 'rejected'); await answer(r.ok ? (r.status==='approved' ? 'Approved \u2705' : 'Rejected \u274C') : 'Already decided.'); // Update the DM so the buttons disappear and show the outcome try{ if(cq.message){ await fetch(`https://api.telegram.org/bot${(env.BOT_TOKEN||'').trim()}/editMessageText`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ chat_id: cq.message.chat.id, message_id: cq.message.message_id, text: (cq.message.text||'Unlock request') + (r.ok ? (r.status==='approved' ? '\n\n\u2705 APPROVED' : '\n\n\u274C REJECTED') : '\n\n(already decided)') }) }); } }catch(e){} } else { await answer(''); } return json({ ok:true }, 200, req, env); } const msg = update && update.message; if(!msg || !msg.text || !msg.chat) return json({ ok:true }, 200, req, env); const chatId = msg.chat.id; const tgId = msg.from && msg.from.id; const text = msg.text.trim(); const [cmdRaw, ...args] = text.split(/\s+/); const cmd = cmdRaw.toLowerCase().replace(/@.*$/,''); const site = (env.FRONTEND_ORIGIN||'').split(',')[0].trim() || 'https://betstackpro.com'; // /start [REFCODE | link_TOKEN] if(cmd==='/start'){ const param = (args[0]||'').trim(); if(param.startsWith('link_')){ // Consume a one-time web->Telegram link token (single use, 10-min TTL). const tok = param.slice(5); const row = await env.DB.prepare(`SELECT user_id,expires_at FROM tg_link_tokens WHERE token=?`).bind(tok).first(); await env.DB.prepare(`DELETE FROM tg_link_tokens WHERE token=?`).bind(tok).run(); if(!row || row.expires_at < Date.now()){ await tgSend(env, chatId, '⚠️ That link has expired. Open Betstack Pro → Settings → Connect my Telegram to get a fresh one.'); return json({ ok:true }, 200, req, env); } await env.DB.prepare(`INSERT INTO tg_links(telegram_id,user_id,created_at) VALUES(?,?,?) ON CONFLICT(telegram_id) DO UPDATE SET user_id=excluded.user_id`).bind(tgId, row.user_id, Date.now()).run(); // If this account has no referrer yet and a ref was parked from a partner deep link, bind it (self-referral blocked). try{ const parked = await env.DB.prepare(`SELECT ref_code FROM tg_pending_refs WHERE telegram_id=?`).bind(tgId).first(); if(parked && parked.ref_code){ const me = await env.DB.prepare(`SELECT ref_code,referred_by FROM users WHERE id=?`).bind(row.user_id).first(); if(me && !me.referred_by && me.ref_code !== parked.ref_code){ const owner = await env.DB.prepare(`SELECT id FROM users WHERE ref_code=?`).bind(parked.ref_code).first(); if(owner && owner.id !== row.user_id){ await env.DB.prepare(`UPDATE users SET referred_by=? WHERE id=?`).bind(parked.ref_code, row.user_id).run(); } } await env.DB.prepare(`DELETE FROM tg_pending_refs WHERE telegram_id=?`).bind(tgId).run(); } }catch(e){} const u = await tgUser(env, tgId); await tgSend(env, chatId, `✅ Account linked! Welcome${u&&u.username?', '+u.username:''}.\n\nCommands:\n/codes — today's bet tips (subscribers)\n/free — free predictions\n/results — our record\n/mystats — referral earnings\n/help — everything`); return json({ ok:true }, 200, req, env); } if(param){ // Partner deep link t.me/bot?start=REFCODE — park first-touch attribution. const code = param.toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,12); if(code){ try{ const owner = await env.DB.prepare(`SELECT id FROM users WHERE ref_code=?`).bind(code).first(); if(owner){ await env.DB.prepare(`INSERT INTO tg_pending_refs(telegram_id,ref_code,created_at) VALUES(?,?,?) ON CONFLICT(telegram_id) DO NOTHING`).bind(tgId, code, Date.now()).run(); } }catch(e){} } } await tgSend(env, chatId, `👋 Welcome to Betstack Pro — transparent sports tips, verified results, no fake wins.\n\n/free — today's free predictions\n/results — our open record\n/plans — membership tiers\n/help — all commands\n\nAlready a member? Link your account from the app: Settings → Connect my Telegram.\n\n18+ only. Betting carries risk — never stake more than you can afford to lose.`); return json({ ok:true }, 200, req, env); } if(cmd==='/myid'){ await tgSend(env, chatId, `Your chat id: ${chatId}\nSet this as TG_ADMIN_CHAT_ID in the Worker to receive unlock-release requests.`); return json({ ok:true }, 200, req, env); } // Admin releases a paid code purchase: /release if(cmd==='/release'){ const linked = await tgUser(env, tgId); if(!linked || linked.role!=='admin'){ await tgSend(env, chatId, 'Only a linked admin account can release purchases.'); return json({ ok:true }, 200, req, env); } const orderId = (args[0]||'').trim(); if(!orderId){ await tgSend(env, chatId, 'Usage: /release '); return json({ ok:true }, 200, req, env); } const p = await env.DB.prepare(`SELECT * FROM purchases WHERE order_id=?`).bind(orderId).first(); if(!p){ await tgSend(env, chatId, '\u274C Order not found.'); return json({ ok:true }, 200, req, env); } if(p.status!=='paid'){ await tgSend(env, chatId, `\u26A0\uFE0F Order is '${p.status}', not paid \u2014 nothing released.`); return json({ ok:true }, 200, req, env); } try{ await env.DB.prepare(`UPDATE purchases SET released=1 WHERE order_id=?`).bind(orderId).run(); }catch(e){ await tgSend(env, chatId, '\u274C released column missing \u2014 run the migration first.'); return json({ ok:true }, 200, req, env); } await tgSend(env, chatId, `\u2705 Released: ${p.code_title||p.item} \u2014 the buyer can now see the code in the app.`); // Nudge the buyer in Telegram too, if they linked their account. try{ const link = await env.DB.prepare(`SELECT telegram_id FROM tg_links WHERE user_id=?`).bind(p.user_id).first(); if(link && link.telegram_id) await tgSend(env, link.telegram_id, `\u2705 Your code \u201C${p.code_title||'purchase'}\u201D is verified and unlocked \u2014 open the app to view it.`); }catch(e){} return json({ ok:true }, 200, req, env); } if(cmd==='/help'){ await tgSend(env, chatId, `Commands\n/free — free daily predictions\n/results — open win/loss record\n/plans — membership tiers\n/codes — today's booking codes (subscribers)\n/status — your membership\n/mylink — your referral link\n/mystats — referral earnings\n/payout — how withdrawals work\n\nWe never DM first and never take payment inside Telegram — only at ${site}. 18+, bet responsibly.`); return json({ ok:true }, 200, req, env); } if(cmd==='/free'){ const { results } = await env.DB.prepare(`SELECT sport,match,pick,odds,risk FROM predictions WHERE status='pending' ORDER BY created_at DESC LIMIT 3`).all(); if(!results || !results.length){ await tgSend(env, chatId, 'No open free predictions right now — check back before kickoff. /results shows how past tips went.'); return json({ ok:true }, 200, req, env); } const lines = results.map(p=>`• ${p.match}\n ${p.pick}${p.odds?` @ ${p.odds}`:''}${p.risk?` · ${p.risk} risk`:''}`); await tgSend(env, chatId, `🎯 Free predictions\n\n${lines.join('\n\n')}\n\nOutcomes are never guaranteed. Full daily codes: /plans`); return json({ ok:true }, 200, req, env); } if(cmd==='/results'){ const agg = await env.DB.prepare(`SELECT SUM(status='won') w, SUM(status='lost') l FROM predictions WHERE settled_at IS NOT NULL AND settled_at > datetime('now','-30 days')`).first(); const w=(agg&&agg.w)||0, l=(agg&&agg.l)||0; await tgSend(env, chatId, `📊 Last 30 days (free predictions)\nWon: ${w} · Lost: ${l}${(w+l)?` · Hit rate: ${Math.round(w/(w+l)*100)}%`:''}\n\nEvery result stays on the record — wins and losses both. Past results never guarantee future ones.`); return json({ ok:true }, 200, req, env); } if(cmd==='/plans'){ await tgSend(env, chatId, `🎟 Membership tiers\nBronze — $15/mo\nSilver — $26/mo\nGold — $49/mo\nVIP — $98/mo\n\nA tier unlocks the full daily bet tips. It is not an investment and makes no profit promises — you place your own bets and keep 100% of what you win.\n\nSubscribe: ${site}`, { reply_markup: { inline_keyboard: [[{ text:'Open Betstack Pro', url: site }]] } }); return json({ ok:true }, 200, req, env); } // ---- linked-account commands ---- const u = await tgUser(env, tgId); if(cmd==='/codes'){ if(!u){ await tgSend(env, chatId, 'Link your account first: open the app → Settings → Connect my Telegram.'); return json({ ok:true }, 200, req, env); } if(!tierActive(u)){ await tgSend(env, chatId, 'Full bet tips unlock with an active membership. See /plans.'); return json({ ok:true }, 200, req, env); } const { results } = await env.DB.prepare(`SELECT title,odds,games,risk FROM codes WHERE active=1 ORDER BY sort_order ASC LIMIT 8`).all(); if(!results || !results.length){ await tgSend(env, chatId, 'No active codes posted yet today — you will find them here and in the app once they are up.'); return json({ ok:true }, 200, req, env); } const lines = results.map(c=>`• ${c.title} — ${c.games||1} game${(c.games||1)>1?'s':''}${c.odds?` · combined @ ${c.odds}`:''}`); await tgSend(env, chatId, `🎫 Today's bet tips\n\n${lines.join('\n\n')}\n\nLoad each code at the listed bookmaker and stake your own money — we never hold your funds. Bet responsibly.`); return json({ ok:true }, 200, req, env); } if(cmd==='/status'){ if(!u){ await tgSend(env, chatId, 'Not linked yet. Open the app → Settings → Connect my Telegram.'); return json({ ok:true }, 200, req, env); } await tgSend(env, chatId, `👤 ${u.username||'Member'}\nMembership: ${(u.tier&&u.tier!=='none')?u.tier.toUpperCase():'None'}\nReferral code: ${u.ref_code||'—'}`); return json({ ok:true }, 200, req, env); } if(cmd==='/mylink'){ if(!u){ await tgSend(env, chatId, 'Link your account first (app → Settings → Connect my Telegram) and your referral link will appear here.'); return json({ ok:true }, 200, req, env); } let code = u.ref_code; if(!code){ code=genRefCode(); try{ await env.DB.prepare(`UPDATE users SET ref_code=? WHERE id=?`).bind(code,u.id).run(); }catch(e){} } await tgSend(env, chatId, `🔗 Your referral links\n\nWeb: ${site}/?ref=${code}\nTelegram: https://t.me/${env.BOT_USERNAME}?start=${code}\n\nYou earn 10% commission on genuine subscription payments from people you refer. No rewards for sign-ups alone.`); return json({ ok:true }, 200, req, env); } if(cmd==='/mystats'){ if(!u){ await tgSend(env, chatId, 'Link your account first: app → Settings → Connect my Telegram.'); return json({ ok:true }, 200, req, env); } let referred = 0; try{ const r = await env.DB.prepare(`SELECT COUNT(*) n FROM users WHERE referred_by=?`).bind(u.ref_code).first(); referred=(r&&r.n)||0; }catch(e){} await tgSend(env, chatId, `📈 Referral stats\nReferred users: ${referred}\nBalance: $${Number(u.ref_balance||0).toFixed(2)}\nCompleted payouts: ${u.payout_count||0}\nMinimum withdrawal: $15\n\nRequest payouts in the app: Wallet → Referrals.`); return json({ ok:true }, 200, req, env); } if(cmd==='/payout'){ await tgSend(env, chatId, `💸 Withdrawals\nMinimum $15, paid in Litecoin to your own wallet. Your first 5 payouts include a quick engagement review, then you are a trusted partner.\n\nRequest in the app: Wallet → Referrals → Request payout.`); return json({ ok:true }, 200, req, env); } // Unknown command / free text await tgSend(env, chatId, `I did not recognise that — try /help for everything I can do.`); return json({ ok:true }, 200, req, env); } // ============ PAYMENTS (NOWPayments crypto checkout) ============ // Create a hosted invoice for a purchase. Auth required. Body: { item: 'code:2' } if(path==='/api/checkout/create' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const { item } = await req.json(); const entry = CATALOG[item]; if(!entry) return json({ ok:false, error:'unknown_item' }, 400, req, env); const orderId = crypto.randomUUID(); const codeId = item.startsWith('code:') ? Number(item.split(':')[1]) : null; await env.DB.prepare(`INSERT INTO purchases(user_id, code_id, code_title, amount, order_id, item, status) VALUES(?,?,?,?,?,?, 'pending')`).bind(sess.uid, codeId, entry.title, entry.amount, orderId, item).run(); const site = (env.FRONTEND_ORIGIN||'').split(',')[0].trim() || 'https://betstackpro.com'; const r = await fetch('https://api.nowpayments.io/v1/invoice', { method:'POST', headers:{ 'x-api-key': env.NOWPAYMENTS_API_KEY, 'Content-Type':'application/json' }, body: JSON.stringify({ price_amount: entry.amount, price_currency: 'usd', pay_currency: 'ltc', order_id: orderId, order_description: entry.title, ipn_callback_url: `${env.PUBLIC_BASE}/api/checkout/webhook`, success_url: `${site}/?paid=${orderId}`, cancel_url: `${site}/?cancelled=1` }) }); const data = await r.json(); if(!r.ok || !data.invoice_url){ console.log('NOWPayments error:', JSON.stringify(data)); return json({ ok:false, error:'gateway_error' }, 502, req, env); } await env.DB.prepare(`UPDATE purchases SET np_invoice_id=? WHERE order_id=?`).bind(String(data.id||''), orderId).run(); return json({ ok:true, invoice_url: data.invoice_url, order_id: orderId }, 200, req, env); } // Webhook from NOWPayments — verify signature, then grant on 'finished'. No cookie auth. if(path==='/api/checkout/webhook' && req.method==='POST'){ const raw = await req.text(); const sigHeader = req.headers.get('x-nowpayments-sig') || ''; const expected = await hmacSha512Hex(JSON.stringify(sortKeys(JSON.parse(raw))), env.NOWPAYMENTS_IPN_SECRET || ''); if(!timingSafeEqual(expected, sigHeader.toLowerCase())) return json({ ok:false, error:'bad_signature' }, 403, req, env); const body = JSON.parse(raw); const st = body.payment_status; if(body.order_id){ if(st === 'finished'){ const p = await env.DB.prepare(`SELECT id, item, status, user_id FROM purchases WHERE order_id=?`).bind(body.order_id).first(); if(p && p.status !== 'paid'){ await env.DB.prepare(`UPDATE purchases SET status='paid' WHERE order_id=?`).bind(body.order_id).run(); const entry = CATALOG[p.item]; // Manual release gate (env.MANUAL_RELEASE==='1'): code purchases wait for admin /release. // Tier purchases always activate instantly. If the gate is off, codes release automatically. try{ if(entry && entry.kind==='code'){ if(env.MANUAL_RELEASE==='1'){ const buyer = await env.DB.prepare(`SELECT username,email FROM users WHERE id=?`).bind(p.user_id).first(); if(env.TG_ADMIN_CHAT_ID) await tgSend(env, env.TG_ADMIN_CHAT_ID, `\uD83D\uDD14 Paid unlock awaiting release\n`+ `User: ${buyer&&buyer.username?buyer.username:'unknown'} (${buyer&&buyer.email?buyer.email:'-'})\n`+ `Item: ${entry.title} \u2014 $${entry.amount}\n`+ `Check the code is fresh, then reply:\n/release ${body.order_id}`); }else{ await env.DB.prepare(`UPDATE purchases SET released=1 WHERE order_id=?`).bind(body.order_id).run(); } } }catch(eRel){ /* released column not migrated yet — status endpoint falls back to auto */ } if(entry && entry.kind==='tier' && p.user_id){ await env.DB.prepare(`UPDATE users SET tier=? WHERE id=?`).bind(entry.tier, p.user_id).run(); } // Referral commission: 10% of the amount paid goes to whoever referred this buyer. try{ if(entry && p.user_id){ const buyer = await env.DB.prepare(`SELECT referred_by FROM users WHERE id=?`).bind(p.user_id).first(); if(buyer && buyer.referred_by){ const ref = await env.DB.prepare(`SELECT id, COALESCE(ref_rate_bps,1000) AS rate FROM users WHERE ref_code=?`).bind(buyer.referred_by).first(); if(ref && ref.id!==p.user_id){ // Partner tier ladder: starter 10% (1000bps) -> trusted 15% -> elite 20%. const commission = Math.round((Number(entry.amount)||0)*(ref.rate||1000))/10000; if(commission>0){ await env.DB.prepare(`UPDATE users SET ref_balance = COALESCE(ref_balance,0)+? WHERE id=?`).bind(commission, ref.id).run(); await env.DB.prepare(`INSERT INTO referral_earnings(id,referrer_id,referred_id,amount,created_at) VALUES(?,?,?,?,?)`) .bind(crypto.randomUUID(), ref.id, p.user_id, commission, Date.now()).run(); } } } } }catch(eRef){ /* referral schema not applied yet */ } } } else if(st === 'failed' || st === 'expired' || st === 'refunded'){ // Transfer didn't complete — mark failed so the app can send the user back to pay. Never overwrite a paid order. await env.DB.prepare(`UPDATE purchases SET status='failed' WHERE order_id=? AND status!='paid'`).bind(body.order_id).run(); } // waiting / confirming / sending / partially_paid → leave as pending until it resolves } return json({ ok:true }, 200, req, env); // always 200 so NOWPayments stops retrying } // Front-end polls this after checkout. Returns paid status (and the code once paid). if(path==='/api/checkout/status' && req.method==='GET'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const orderId = url.searchParams.get('order_id'); const p = await env.DB.prepare(`SELECT * FROM purchases WHERE order_id=?`).bind(orderId).first(); if(!p || p.user_id !== sess.uid) return json({ ok:false, error:'not_found' }, 404, req, env); const entry = CATALOG[p.item]; const out = { ok:true, status: p.status }; if(p.status==='paid' && entry && entry.kind==='code'){ // Gate active + not yet released -> payment confirmed but code held for admin check. if(env.MANUAL_RELEASE==='1' && !p.released){ out.pending_release = true; } else { try{ const row = await env.DB.prepare(`SELECT selections FROM codes WHERE id=?`).bind(p.code_id).first(); out.selections = JSON.parse((row&&row.selections)||'[]'); }catch(e){ out.selections=[]; } } } if(p.status==='paid' && entry && entry.kind==='tier') out.tier = entry.tier; return json(out, 200, req, env); } // ============ REVIEWS (honest, real user ratings) ============ // Submit/update a review. Auth required. One review per user. Body: { rating:1-5, body } if(path==='/api/reviews' && req.method==='POST'){ const sess = await readSession(tokenFrom(req), JWT); if(!sess) return json({ ok:false, error:'unauthorized' }, 401, req, env); const { rating, body } = await req.json(); const rt = parseInt(rating, 10); if(!(rt>=1 && rt<=5)) return json({ ok:false, error:'bad_rating' }, 400, req, env); const text = (body==null?'':String(body)).slice(0, 600); const u = await env.DB.prepare(`SELECT username FROM users WHERE id=?`).bind(sess.uid).first(); const uname = (u && u.username) ? u.username : 'Member'; await env.DB.prepare(`INSERT INTO reviews(user_id, username, rating, body, created_at) VALUES(?,?,?,?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET username=excluded.username, rating=excluded.rating, body=excluded.body, created_at=datetime('now')`) .bind(sess.uid, uname, rt, text).run(); return json({ ok:true }, 200, req, env); } // Public: recent reviews + real average. Starts empty until real users review. if(path==='/api/reviews' && req.method==='GET'){ const agg = await env.DB.prepare(`SELECT COUNT(*) c, AVG(rating) a FROM reviews`).first(); const rows = await env.DB.prepare(`SELECT username, rating, body, created_at FROM reviews ORDER BY created_at DESC LIMIT 30`).all(); return json({ ok:true, count: (agg&&agg.c)||0, avg: (agg&&agg.a)? Math.round(agg.a*10)/10 : 0, items: (rows.results||[]) }, 200, req, env); } if(path==='/api/support-chat' && req.method==='POST') return json({ ok:false, error:'not_implemented', note:'Stage 2: forward the message to an LLM with a support prompt.' }, 501, req, env); return json({ ok:false, error:'not_found' }, 404, req, env); }catch(err){ console.log('Worker error:', String(err)); return json({ ok:false, error:'server_error' }, 500, req, env); } } };