Files
vyndr/specs/feature-1-5-bet-submission.md
T

10 KiB
Raw Blame History

Feature 1.5 — Bet Submission

Overview

Three ways for users to log their bets: quick slip (structured form), screenshot upload (OCR extraction), and sportsbook sync (stub for future OAuth integration). Every bet writes to the bets table, optionally links to a scan_session, and can be settled later with an outcome. Settled bets feed the performance table for ROI/win-rate tracking.

Dependencies

  • Feature 1.4 — Database Schema (bets, outcomes, performance tables)
  • Feature 2.1 — Parlay Scan (scan_sessions for linking)

Auth

All endpoints require Supabase JWT (same requireAuth middleware from Feature 2.1). All tiers can submit bets — no tier gating.

Endpoints

POST /api/bets/quickslip

Structured bet submission. User fills a form or sends structured JSON.

Request body:

{
  "legs": [
    {
      "player": "Nikola Jokic",
      "stat_type": "points",
      "line": 26.5,
      "direction": "over",
      "odds": -110
    }
  ],
  "amount": 20.00,
  "book": "draftkings",
  "bet_type": "straight",
  "scan_session_id": "uuid-or-null",
  "placed_at": "2026-03-21T18:00:00Z"
}

Response (201):

{
  "bet_id": "uuid",
  "status": "pending",
  "amount": 20.00,
  "potential_payout": 38.18,
  "bet_type": "straight",
  "book": "draftkings",
  "legs": 1,
  "scan_session_id": "uuid-or-null",
  "created_at": "2026-03-21T18:05:00Z"
}

POST /api/bets/screenshot

Image upload. Server extracts bet data via OCR, then creates the bet.

Request: multipart/form-data

Field Type Required Description
image file yes Screenshot image (JPEG, PNG, max 5MB)
book string yes Sportsbook the screenshot is from
scan_session_id string no Link to a prior scan

Response (201):

{
  "bet_id": "uuid",
  "status": "pending",
  "extracted": {
    "legs": [
      { "player": "Nikola Jokic", "stat_type": "points", "line": 26.5, "direction": "over", "odds": -110 }
    ],
    "amount": 20.00,
    "potential_payout": 38.18,
    "bet_type": "parlay",
    "confidence": 0.85
  },
  "needs_confirmation": true,
  "message": "We extracted this from your screenshot. Confirm or edit before saving."
}

Note: For MVP, the OCR pipeline uses a stub that returns a structured placeholder. Full OCR (Tesseract or Claude Vision) is a Phase 3 enhancement. The endpoint accepts the image, stores the raw file reference, and returns extracted data for user confirmation.

POST /api/bets/screenshot/confirm

User confirms or edits the OCR extraction, then saves the bet.

Request body:

{
  "extracted_bet_id": "uuid",
  "legs": [
    { "player": "Nikola Jokic", "stat_type": "points", "line": 26.5, "direction": "over", "odds": -110 }
  ],
  "amount": 20.00,
  "book": "draftkings",
  "bet_type": "parlay",
  "scan_session_id": "uuid-or-null"
}

Response (201): Same shape as quickslip response.

POST /api/bets/sync

Stub for future sportsbook OAuth sync. Returns a "coming soon" response.

Response (200):

{
  "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

Settle a bet with outcome. Updates the bet status and creates an outcome record for each leg.

Request body:

{
  "status": "won",
  "leg_outcomes": [
    { "player": "Nikola Jokic", "stat_type": "points", "actual_value": 28.0, "result": "hit" }
  ]
}

Response (200):

{
  "bet_id": "uuid",
  "status": "won",
  "settled_at": "2026-03-22T06:00:00Z",
  "amount": 20.00,
  "potential_payout": 38.18,
  "profit": 18.18
}

GET /api/bets

List user's bets with optional filters.

Query params:

Param Type Required Default Description
status string no all Filter: pending, won, lost, push, void
book string no all Filter by sportsbook
limit int no 20 Pagination limit (max 100)
offset int no 0 Pagination offset

Response (200):

{
  "bets": [
    {
      "id": "uuid",
      "amount": 20.00,
      "potential_payout": 38.18,
      "book": "draftkings",
      "bet_type": "straight",
      "status": "pending",
      "slip_data": { "legs": [...], "total_odds": -110 },
      "submission_method": "quickslip",
      "scan_session_id": null,
      "placed_at": "2026-03-21T18:00:00Z",
      "settled_at": null
    }
  ],
  "total": 15,
  "limit": 20,
  "offset": 0
}

GET /api/bets/performance

Returns aggregated performance stats. Recalculates from settled bets and upserts into performance table.

Response (200):

{
  "weekly": { "roi": 12.5, "win_rate": 60.0, "sample_size": 10, "total_wagered": 200.00, "total_profit": 25.00 },
  "monthly": { "roi": 8.3, "win_rate": 55.0, "sample_size": 40, "total_wagered": 800.00, "total_profit": 66.40 },
  "all_time": { "roi": 6.1, "win_rate": 52.0, "sample_size": 120, "total_wagered": 2400.00, "total_profit": 146.40 }
}

Error Responses

Status When Body
400 Missing required fields, invalid bet_type, empty legs { "error": "legs array is required" }
401 No auth token { "error": "Authentication required" }
404 Bet not found or not owned { "error": "Bet not found" }
413 Screenshot too large (> 5MB) { "error": "Image must be under 5MB" }
422 Invalid settlement (already settled, invalid status) { "error": "Bet already settled" }

Payout Calculation

For straight bets:

If odds < 0: payout = amount + (amount / (|odds| / 100))
If odds > 0: payout = amount + (amount * (odds / 100))

For parlays, multiply individual leg payouts:

parlay_multiplier = product of (1 + single_payout_ratio) for each leg
payout = amount * parlay_multiplier

Performance Calculation

Triggered on each bet settlement. Recalculates from all settled bets for the user.

For each period (weekly, monthly, all_time):
  - Filter settled bets within the period
  - total_wagered = sum of amount for all settled bets
  - total_profit = sum of (payout - amount) for won bets - sum of amount for lost bets
  - roi = (total_profit / total_wagered) * 100
  - win_rate = (won_count / (won_count + lost_count)) * 100
  - sample_size = total settled bets

Upsert into performance table (unique index on user_id + period).

Period boundaries:

  • weekly: current ISO week (MondaySunday)
  • monthly: current calendar month
  • all_time: all settled bets ever

Scan Session Linking

When a user submits a bet with scan_session_id:

  • Validate the session exists and belongs to the user
  • Store the reference in bets.slip_data.scan_session_id
  • This enables "how many bets followed VYNDR grades" analytics later

Service Architecture

src/
├── services/
│   ├── betService.js          # Create bet, settle bet, list bets
│   ├── payoutCalculator.js    # Payout math for straight/parlay/teaser
│   ├── performanceService.js  # Recalculate and upsert performance stats
│   └── ocrStub.js             # Stub OCR for screenshot extraction (MVP)
├── routes/
│   └── bets.js                # All bet endpoints
  • betService.js — Core CRUD: create from quickslip/screenshot, settle, list with filters.
  • payoutCalculator.js — Pure function. Calculates potential payout from odds + amount + bet type.
  • performanceService.js — Queries settled bets, computes ROI/win_rate/profit, upserts performance table.
  • ocrStub.js — MVP stub. Returns a "needs_confirmation" response. Real OCR layered in Phase 3.

Acceptance Criteria

  1. POST /api/bets/quickslip creates a bet with correct slip_data, amount, payout, and status "pending"
  2. POST /api/bets/screenshot accepts image upload and returns extracted data with needs_confirmation: true
  3. POST /api/bets/screenshot/confirm saves the confirmed bet to the database
  4. POST /api/bets/sync returns "coming_soon" stub response
  5. PATCH /api/bets/:id/settle updates bet status and creates outcome records
  6. PATCH /api/bets/:id/settle rejects settling an already-settled bet (422)
  7. GET /api/bets returns user's bets with status/book/limit/offset filters
  8. GET /api/bets/performance returns ROI, win_rate, sample_size for weekly/monthly/all_time
  9. Payout calculation correct for straight bets (positive and negative odds)
  10. Payout calculation correct for parlays (multiplied leg payouts)
  11. Performance recalculated on each settlement
  12. Scan session linking validated (session must exist and belong to user)
  13. All endpoints require auth (401 without token)
  14. Image upload rejects files > 5MB (413)

Test Plan

Unit Tests (payoutCalculator.js)

  • Straight bet with negative odds (-110, $20) → correct payout
  • Straight bet with positive odds (+150, $20) → correct payout
  • Parlay payout with 2 legs → multiplied correctly
  • Parlay payout with 3 legs → multiplied correctly
  • Edge case: even odds (-100) → doubles the bet

Unit Tests (performanceService.js)

  • Calculates ROI correctly from mix of won/lost bets
  • Win rate = won / (won + lost) * 100 (excludes push/void)
  • Weekly period filters to current week only
  • Empty settled bets returns zeroes
  • Upserts into performance table (not duplicate rows)

Unit Tests (ocrStub.js)

  • Returns structured placeholder with needs_confirmation: true
  • Includes book from request

Integration Tests (routes/bets.js)

  • Quickslip: create straight bet → 201 with correct payout
  • Quickslip: create parlay → 201 with multiplied payout
  • Screenshot: upload image → 201 with needs_confirmation
  • Screenshot: confirm → 201 with saved bet
  • Sync: returns coming_soon stub
  • Settle: pending bet → won → 200 with settled_at
  • Settle: already settled → 422
  • List: returns user's bets with pagination
  • List: status filter works
  • Performance: returns stats for all 3 periods
  • Auth: no token → 401
  • Validation: missing legs → 400

Open Questions

  • Screenshot storage: For MVP, we don't actually store the image file — the stub extracts nothing and the user manually enters data via the confirm endpoint. When real OCR is added (Phase 3), we'll need file storage (Supabase Storage or S3). No infrastructure needed now.