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:
Kev
2026-03-22 05:11:42 -04:00
parent 2366660f5e
commit ed6502a880
12 changed files with 1310 additions and 37 deletions
+2
View File
@@ -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;
+131
View File
@@ -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;
+213
View File
@@ -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 };
+15
View File
@@ -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 };
+31
View File
@@ -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 };
+106
View File
@@ -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 };