feat: Feature 1.5 — Bet Submission with 3 methods + performance tracking
Three submission methods: - POST /api/bets/quickslip — structured bet entry - POST /api/bets/screenshot — stub OCR with confirm flow - POST /api/bets/sync — coming soon stub Full bet lifecycle: - PATCH /api/bets/:id/settle — settle with outcome, recalculates performance - GET /api/bets — list with status/book/pagination filters - GET /api/bets/performance — ROI, win rate, profit (weekly/monthly/all_time) Payout calculator handles straight bets (American odds) and parlays (multiplied leg payouts). Performance service recalculates on each settlement and upserts into performance table. 33 new tests, 221 total (194 Node.js + 27 Python), all passing. All backend features for Phase 1 + Phase 2 now complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ const analyzeRoutes = require('./routes/analyze');
|
||||
const scanRoutes = require('./routes/scan');
|
||||
const movementsRoutes = require('./routes/movements');
|
||||
const alertsRoutes = require('./routes/alerts');
|
||||
const betsRoutes = require('./routes/bets');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -13,5 +14,6 @@ app.use('/api/analyze', analyzeRoutes);
|
||||
app.use('/api/scan', scanRoutes);
|
||||
app.use('/api/movements', movementsRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/bets', betsRoutes);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { createBet, createBetFromScreenshot, settleBet, listBets } = require('../services/betService');
|
||||
const { extractFromScreenshot } = require('../services/ocrStub');
|
||||
const { recalculatePerformance } = require('../services/performanceService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_BET_TYPES = new Set(['straight', 'parlay', 'teaser', 'round_robin']);
|
||||
const VALID_SETTLE_STATUSES = new Set(['won', 'lost', 'push', 'void']);
|
||||
|
||||
function validateQuickslip(body) {
|
||||
if (!Array.isArray(body.legs) || body.legs.length === 0) return 'legs array is required';
|
||||
if (body.amount == null || body.amount <= 0) return 'amount is required and must be positive';
|
||||
if (!body.book) return 'book is required';
|
||||
if (!body.bet_type) return 'bet_type is required';
|
||||
if (!VALID_BET_TYPES.has(body.bet_type)) return `Invalid bet_type: ${body.bet_type}`;
|
||||
for (let i = 0; i < body.legs.length; i++) {
|
||||
const leg = body.legs[i];
|
||||
if (!leg.player) return `leg ${i}: player is required`;
|
||||
if (!leg.stat_type) return `leg ${i}: stat_type is required`;
|
||||
if (leg.line == null) return `leg ${i}: line is required`;
|
||||
if (!leg.direction) return `leg ${i}: direction is required`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST /api/bets/quickslip
|
||||
router.post('/quickslip', requireAuth, async (req, res) => {
|
||||
const error = validateQuickslip(req.body);
|
||||
if (error) return res.status(400).json({ error });
|
||||
|
||||
try {
|
||||
const result = await createBet(req.user.id, req.body);
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
console.error('[BetonBLK] Quickslip error:', err.message);
|
||||
return res.status(503).json({ error: 'Bet submission failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/bets/screenshot
|
||||
router.post('/screenshot', requireAuth, async (req, res) => {
|
||||
// For MVP: accept the request but return stub extraction
|
||||
const book = req.body?.book || req.query?.book || 'unknown';
|
||||
|
||||
// In a real implementation, we'd parse multipart form data and process the image
|
||||
// For MVP, just return the stub
|
||||
const extracted = extractFromScreenshot(book);
|
||||
|
||||
return res.status(201).json({
|
||||
bet_id: null,
|
||||
status: 'pending_confirmation',
|
||||
extracted,
|
||||
needs_confirmation: true,
|
||||
message: extracted.message,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/bets/screenshot/confirm
|
||||
router.post('/screenshot/confirm', requireAuth, async (req, res) => {
|
||||
const error = validateQuickslip(req.body);
|
||||
if (error) return res.status(400).json({ error });
|
||||
|
||||
try {
|
||||
const result = await createBetFromScreenshot(req.user.id, req.body);
|
||||
return res.status(201).json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
console.error('[BetonBLK] Screenshot confirm error:', err.message);
|
||||
return res.status(503).json({ error: 'Bet submission failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/bets/sync
|
||||
router.post('/sync', requireAuth, async (req, res) => {
|
||||
return res.json({
|
||||
status: 'coming_soon',
|
||||
message: 'Sportsbook sync is coming soon. Use quick slip or screenshot for now.',
|
||||
supported_books: ['draftkings', 'fanduel', 'betmgm'],
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /api/bets/:id/settle
|
||||
router.patch('/:id/settle', requireAuth, async (req, res) => {
|
||||
const { status, leg_outcomes } = req.body;
|
||||
|
||||
if (!status || !VALID_SETTLE_STATUSES.has(status)) {
|
||||
return res.status(400).json({ error: `Invalid status. Must be one of: ${[...VALID_SETTLE_STATUSES].join(', ')}` });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await settleBet(req.user.id, req.params.id, { status, leg_outcomes });
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404) return res.status(404).json({ error: err.message });
|
||||
if (err.statusCode === 422) return res.status(422).json({ error: err.message });
|
||||
console.error('[BetonBLK] Settle error:', err.message);
|
||||
return res.status(503).json({ error: 'Settlement failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/bets
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await listBets(req.user.id, {
|
||||
status: req.query.status || null,
|
||||
book: req.query.book || null,
|
||||
limit: Math.min(parseInt(req.query.limit) || 20, 100),
|
||||
offset: parseInt(req.query.offset) || 0,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] List bets error:', err.message);
|
||||
return res.status(503).json({ error: 'Failed to fetch bets' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/bets/performance
|
||||
router.get('/performance', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await recalculatePerformance(req.user.id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Performance error:', err.message);
|
||||
return res.status(503).json({ error: 'Failed to calculate performance' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,213 @@
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { calculatePayout } = require('./payoutCalculator');
|
||||
const { recalculatePerformance } = require('./performanceService');
|
||||
|
||||
async function createBet(userId, { legs, amount, book, bet_type, scan_session_id, placed_at }) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Validate scan_session_id if provided
|
||||
if (scan_session_id) {
|
||||
const { data: session } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('id')
|
||||
.eq('id', scan_session_id)
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (!session) {
|
||||
const err = new Error('Scan session not found or does not belong to user');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const legsOdds = legs.map((l) => l.odds).filter((o) => o != null);
|
||||
const potentialPayout = calculatePayout(amount, bet_type, legsOdds);
|
||||
|
||||
// Compute total odds for slip_data
|
||||
let totalOdds = null;
|
||||
if (legsOdds.length === 1) {
|
||||
totalOdds = legsOdds[0];
|
||||
} else if (legsOdds.length > 1) {
|
||||
// Convert to decimal, multiply, convert back to American
|
||||
let decimalProduct = 1;
|
||||
for (const odds of legsOdds) {
|
||||
decimalProduct *= odds < 0 ? 1 + (100 / Math.abs(odds)) : 1 + (odds / 100);
|
||||
}
|
||||
totalOdds = decimalProduct >= 2
|
||||
? Math.round((decimalProduct - 1) * 100)
|
||||
: Math.round(-100 / (decimalProduct - 1));
|
||||
}
|
||||
|
||||
const slipData = {
|
||||
legs,
|
||||
total_odds: totalOdds,
|
||||
scan_session_id: scan_session_id || null,
|
||||
};
|
||||
|
||||
const { data: bet, error } = await supabase
|
||||
.from('bets')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
amount,
|
||||
potential_payout: potentialPayout,
|
||||
slip_data: slipData,
|
||||
book,
|
||||
bet_type,
|
||||
submission_method: 'quickslip',
|
||||
status: 'pending',
|
||||
placed_at: placed_at || new Date().toISOString(),
|
||||
})
|
||||
.select('id, status, amount, potential_payout, bet_type, book, created_at')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
bet_id: bet.id,
|
||||
status: bet.status,
|
||||
amount: parseFloat(bet.amount),
|
||||
potential_payout: parseFloat(bet.potential_payout),
|
||||
bet_type: bet.bet_type,
|
||||
book: bet.book,
|
||||
legs: legs.length,
|
||||
scan_session_id: scan_session_id || null,
|
||||
created_at: bet.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function createBetFromScreenshot(userId, { legs, amount, book, bet_type, scan_session_id }) {
|
||||
// Same as quickslip but with submission_method = 'screenshot'
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
if (scan_session_id) {
|
||||
const { data: session } = await supabase
|
||||
.from('scan_sessions')
|
||||
.select('id')
|
||||
.eq('id', scan_session_id)
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (!session) {
|
||||
const err = new Error('Scan session not found or does not belong to user');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const legsOdds = legs.map((l) => l.odds).filter((o) => o != null);
|
||||
const potentialPayout = calculatePayout(amount, bet_type, legsOdds);
|
||||
|
||||
const slipData = {
|
||||
legs,
|
||||
scan_session_id: scan_session_id || null,
|
||||
};
|
||||
|
||||
const { data: bet, error } = await supabase
|
||||
.from('bets')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
amount,
|
||||
potential_payout: potentialPayout,
|
||||
slip_data: slipData,
|
||||
book,
|
||||
bet_type,
|
||||
submission_method: 'screenshot',
|
||||
status: 'pending',
|
||||
placed_at: new Date().toISOString(),
|
||||
})
|
||||
.select('id, status, amount, potential_payout, bet_type, book, created_at')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
bet_id: bet.id,
|
||||
status: bet.status,
|
||||
amount: parseFloat(bet.amount),
|
||||
potential_payout: parseFloat(bet.potential_payout),
|
||||
bet_type: bet.bet_type,
|
||||
book: bet.book,
|
||||
legs: legs.length,
|
||||
scan_session_id: scan_session_id || null,
|
||||
created_at: bet.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function settleBet(userId, betId, { status, leg_outcomes }) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Fetch the bet
|
||||
const { data: bet, error: fetchError } = await supabase
|
||||
.from('bets')
|
||||
.select('*')
|
||||
.eq('id', betId)
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !bet) {
|
||||
const err = new Error('Bet not found');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (bet.status !== 'pending') {
|
||||
const err = new Error('Bet already settled');
|
||||
err.statusCode = 422;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Update bet status
|
||||
const settledAt = new Date().toISOString();
|
||||
const { error: updateError } = await supabase
|
||||
.from('bets')
|
||||
.update({ status, settled_at: settledAt })
|
||||
.eq('id', betId);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Calculate profit
|
||||
const amount = parseFloat(bet.amount);
|
||||
const payout = parseFloat(bet.potential_payout || 0);
|
||||
let profit = 0;
|
||||
if (status === 'won') profit = payout - amount;
|
||||
else if (status === 'lost') profit = -amount;
|
||||
|
||||
// Recalculate performance
|
||||
await recalculatePerformance(userId);
|
||||
|
||||
return {
|
||||
bet_id: betId,
|
||||
status,
|
||||
settled_at: settledAt,
|
||||
amount,
|
||||
potential_payout: payout,
|
||||
profit: Math.round(profit * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async function listBets(userId, { status, book, limit = 20, offset = 0 }) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
let query = supabase
|
||||
.from('bets')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.order('placed_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (status) query = query.eq('status', status);
|
||||
if (book) query = query.eq('book', book);
|
||||
|
||||
const { data: bets, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
bets: bets || [],
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createBet, createBetFromScreenshot, settleBet, listBets };
|
||||
@@ -0,0 +1,15 @@
|
||||
function extractFromScreenshot(book) {
|
||||
// MVP stub — returns placeholder that requires user confirmation
|
||||
return {
|
||||
legs: [],
|
||||
amount: null,
|
||||
potential_payout: null,
|
||||
bet_type: null,
|
||||
confidence: 0,
|
||||
book: book || 'unknown',
|
||||
needs_confirmation: true,
|
||||
message: 'We extracted this from your screenshot. Confirm or edit before saving.',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { extractFromScreenshot };
|
||||
@@ -0,0 +1,31 @@
|
||||
function calculateStraightPayout(amount, odds) {
|
||||
if (odds < 0) {
|
||||
return amount + (amount / (Math.abs(odds) / 100));
|
||||
}
|
||||
return amount + (amount * (odds / 100));
|
||||
}
|
||||
|
||||
function calculateParlayPayout(amount, legsOdds) {
|
||||
let multiplier = 1;
|
||||
for (const odds of legsOdds) {
|
||||
if (odds < 0) {
|
||||
multiplier *= 1 + (100 / Math.abs(odds));
|
||||
} else {
|
||||
multiplier *= 1 + (odds / 100);
|
||||
}
|
||||
}
|
||||
return amount * multiplier;
|
||||
}
|
||||
|
||||
function calculatePayout(amount, betType, legsOdds) {
|
||||
if (!legsOdds || legsOdds.length === 0) return amount;
|
||||
|
||||
if (betType === 'straight' || legsOdds.length === 1) {
|
||||
return Math.round(calculateStraightPayout(amount, legsOdds[0]) * 100) / 100;
|
||||
}
|
||||
|
||||
// parlay, teaser, round_robin all use multiplied odds for MVP
|
||||
return Math.round(calculateParlayPayout(amount, legsOdds) * 100) / 100;
|
||||
}
|
||||
|
||||
module.exports = { calculatePayout, calculateStraightPayout, calculateParlayPayout };
|
||||
@@ -0,0 +1,106 @@
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
async function recalculatePerformance(userId) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
// Fetch all settled bets for this user
|
||||
const { data: bets } = await supabase
|
||||
.from('bets')
|
||||
.select('amount, potential_payout, status, settled_at, placed_at')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['won', 'lost', 'push']);
|
||||
|
||||
if (!bets || bets.length === 0) {
|
||||
return { weekly: null, monthly: null, all_time: null };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const weekStart = getWeekStart(now);
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
|
||||
const periods = {
|
||||
weekly: { start: weekStart },
|
||||
monthly: { start: monthStart },
|
||||
all_time: { start: new Date(0) },
|
||||
};
|
||||
|
||||
const results = {};
|
||||
|
||||
for (const [period, { start }] of Object.entries(periods)) {
|
||||
const periodBets = bets.filter((b) => {
|
||||
const settledAt = new Date(b.settled_at || b.placed_at);
|
||||
return settledAt >= start;
|
||||
});
|
||||
|
||||
const stats = computeStats(periodBets);
|
||||
results[period] = stats;
|
||||
|
||||
// Upsert into performance table
|
||||
await supabase.from('performance').upsert({
|
||||
user_id: userId,
|
||||
period,
|
||||
roi: stats.roi,
|
||||
win_rate: stats.win_rate,
|
||||
sample_size: stats.sample_size,
|
||||
total_wagered: stats.total_wagered,
|
||||
total_profit: stats.total_profit,
|
||||
calculated_at: now.toISOString(),
|
||||
}, {
|
||||
onConflict: 'user_id,period',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function computeStats(bets) {
|
||||
if (bets.length === 0) {
|
||||
return { roi: 0, win_rate: 0, sample_size: 0, total_wagered: 0, total_profit: 0 };
|
||||
}
|
||||
|
||||
let totalWagered = 0;
|
||||
let totalProfit = 0;
|
||||
let wonCount = 0;
|
||||
let lostCount = 0;
|
||||
|
||||
for (const bet of bets) {
|
||||
const amount = parseFloat(bet.amount);
|
||||
const payout = parseFloat(bet.potential_payout || 0);
|
||||
|
||||
totalWagered += amount;
|
||||
|
||||
if (bet.status === 'won') {
|
||||
totalProfit += (payout - amount);
|
||||
wonCount++;
|
||||
} else if (bet.status === 'lost') {
|
||||
totalProfit -= amount;
|
||||
lostCount++;
|
||||
}
|
||||
// push: no profit change, not counted in win/loss
|
||||
}
|
||||
|
||||
const sampleSize = bets.length;
|
||||
const roi = totalWagered > 0 ? Math.round((totalProfit / totalWagered) * 1000) / 10 : 0;
|
||||
const winRate = (wonCount + lostCount) > 0
|
||||
? Math.round((wonCount / (wonCount + lostCount)) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
return {
|
||||
roi,
|
||||
win_rate: winRate,
|
||||
sample_size: sampleSize,
|
||||
total_wagered: Math.round(totalWagered * 100) / 100,
|
||||
total_profit: Math.round(totalProfit * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
function getWeekStart(date) {
|
||||
const d = new Date(date);
|
||||
const day = d.getUTCDay();
|
||||
const diff = day === 0 ? 6 : day - 1; // Monday = 0
|
||||
d.setUTCDate(d.getUTCDate() - diff);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
module.exports = { recalculatePerformance, computeStats, getWeekStart };
|
||||
Reference in New Issue
Block a user