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
+21
View File
@@ -0,0 +1,21 @@
const { extractFromScreenshot } = require('../../src/services/ocrStub');
describe('ocrStub', () => {
test('returns needs_confirmation: true', () => {
const result = extractFromScreenshot('draftkings');
expect(result.needs_confirmation).toBe(true);
expect(result.confidence).toBe(0);
expect(result.book).toBe('draftkings');
});
test('includes message for user', () => {
const result = extractFromScreenshot('fanduel');
expect(result.message).toContain('screenshot');
expect(result.book).toBe('fanduel');
});
test('defaults book to unknown', () => {
const result = extractFromScreenshot();
expect(result.book).toBe('unknown');
});
});
+63
View File
@@ -0,0 +1,63 @@
const { calculatePayout, calculateStraightPayout, calculateParlayPayout } = require('../../src/services/payoutCalculator');
describe('payoutCalculator', () => {
describe('straight bets', () => {
test('negative odds: -110, $20 → $38.18', () => {
const payout = calculatePayout(20, 'straight', [-110]);
expect(payout).toBeCloseTo(38.18, 1);
});
test('positive odds: +150, $20 → $50', () => {
const payout = calculatePayout(20, 'straight', [150]);
expect(payout).toBe(50);
});
test('even odds: -100, $20 → $40', () => {
const payout = calculatePayout(20, 'straight', [-100]);
expect(payout).toBe(40);
});
test('heavy favorite: -200, $20 → $30', () => {
const payout = calculatePayout(20, 'straight', [-200]);
expect(payout).toBe(30);
});
test('big underdog: +300, $10 → $40', () => {
const payout = calculatePayout(10, 'straight', [300]);
expect(payout).toBe(40);
});
});
describe('parlay bets', () => {
test('2-leg parlay: -110 and -110, $20', () => {
const payout = calculatePayout(20, 'parlay', [-110, -110]);
// Each leg: 1 + 100/110 = 1.909..., product = 3.647, payout = 20 * 3.647 = 72.93
expect(payout).toBeCloseTo(72.93, 0);
});
test('3-leg parlay: -110, -110, +150, $10', () => {
const payout = calculatePayout(10, 'parlay', [-110, -110, 150]);
// 1.909 * 1.909 * 2.5 = 9.118, payout = 91.18
expect(payout).toBeCloseTo(91.18, 0);
});
test('2-leg parlay: +200 and +200, $10', () => {
const payout = calculatePayout(10, 'parlay', [200, 200]);
// 3 * 3 = 9, payout = 90
expect(payout).toBe(90);
});
});
describe('edge cases', () => {
test('empty odds returns amount', () => {
expect(calculatePayout(20, 'straight', [])).toBe(20);
});
test('single leg in parlay treated as straight', () => {
const straight = calculatePayout(20, 'straight', [-110]);
const parlaySingle = calculatePayout(20, 'parlay', [-110]);
// Single leg parlay should use parlay calc but result is same as straight
expect(parlaySingle).toBeCloseTo(straight, 1);
});
});
});
+70
View File
@@ -0,0 +1,70 @@
const { computeStats, getWeekStart } = require('../../src/services/performanceService');
describe('performanceService', () => {
describe('computeStats', () => {
test('calculates ROI correctly from won/lost mix', () => {
const bets = [
{ amount: '20', potential_payout: '38.18', status: 'won', settled_at: '2026-03-21' },
{ amount: '20', potential_payout: '38.18', status: 'lost', settled_at: '2026-03-21' },
{ amount: '20', potential_payout: '38.18', status: 'won', settled_at: '2026-03-21' },
];
const stats = computeStats(bets);
// Won: 18.18 + 18.18 = 36.36 profit, Lost: -20 = -20
// Total profit: 16.36, Total wagered: 60
// ROI: 16.36/60 * 100 = 27.3%
expect(stats.roi).toBeCloseTo(27.3, 0);
expect(stats.total_wagered).toBe(60);
expect(stats.total_profit).toBeCloseTo(16.36, 1);
});
test('win rate = won / (won + lost), excludes push', () => {
const bets = [
{ amount: '20', potential_payout: '38', status: 'won', settled_at: '2026-03-21' },
{ amount: '20', potential_payout: '38', status: 'lost', settled_at: '2026-03-21' },
{ amount: '20', potential_payout: '38', status: 'push', settled_at: '2026-03-21' },
];
const stats = computeStats(bets);
expect(stats.win_rate).toBe(50);
expect(stats.sample_size).toBe(3);
});
test('empty bets returns zeroes', () => {
const stats = computeStats([]);
expect(stats.roi).toBe(0);
expect(stats.win_rate).toBe(0);
expect(stats.sample_size).toBe(0);
expect(stats.total_wagered).toBe(0);
expect(stats.total_profit).toBe(0);
});
test('all losses gives negative ROI', () => {
const bets = [
{ amount: '20', potential_payout: '38', status: 'lost', settled_at: '2026-03-21' },
{ amount: '30', potential_payout: '57', status: 'lost', settled_at: '2026-03-21' },
];
const stats = computeStats(bets);
expect(stats.roi).toBe(-100);
expect(stats.win_rate).toBe(0);
});
});
describe('getWeekStart', () => {
test('returns Monday for a Wednesday', () => {
// 2026-03-18 is a Wednesday
const ws = getWeekStart(new Date('2026-03-18T12:00:00Z'));
expect(ws.getUTCDay()).toBe(1); // Monday
expect(ws.toISOString().split('T')[0]).toBe('2026-03-16');
});
test('returns Monday for a Monday', () => {
const ws = getWeekStart(new Date('2026-03-16T12:00:00Z'));
expect(ws.toISOString().split('T')[0]).toBe('2026-03-16');
});
test('returns previous Monday for a Sunday', () => {
// 2026-03-22 is a Sunday
const ws = getWeekStart(new Date('2026-03-22T12:00:00Z'));
expect(ws.toISOString().split('T')[0]).toBe('2026-03-16');
});
});
});