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 };
|
||||
Reference in New Issue
Block a user