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
+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 };