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:
@@ -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