feat: Feature 2.1 — Parlay Scan with correlation detection + monetization

POST /api/scan/parlay — authenticated parlay analysis:
- Supabase JWT auth middleware (auth.getUser verification)
- 5 correlation types detected between legs (same_game, same_team,
  same_player_conflicting, positive_correlation, blowout_cascade)
- Overall parlay grading (A/B/C/D) with correlation penalty adjustments
- Free tier: 5 scans/month, atomic scan count increment
- Scan 5: full analysis + personalized upgrade pitch
- Scan 6+: 403 block with upgrade pitch
- Pitch personalization from scan history (top stats, grades, tier rec)
- DB writes: picks + scan_sessions per scan

30 new tests, 158 total (131 Node.js + 27 Python), all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-21 12:45:15 -04:00
parent c8c0962e56
commit 411cb6f196
14 changed files with 1539 additions and 48 deletions
+38 -48
View File
@@ -4,47 +4,36 @@
2026-03-21
## Current Phase
Phase 1Foundation (COMPLETE)
Phase 2Core Product (IN PROGRESS)
## What Has Shipped
### Feature 1.1Odds API Integration (COMPLETE)
- GET /api/odds/nba — live NBA player props from DraftKings, FanDuel, BetMGM
- GET /api/odds/ncaab — NCAAB props (with off-season detection)
- Normalizer: pairs Over/Under outcomes, maps 8 market types, filters to 3 books
- Spreads market added for blowout risk detection (Feature 1.3)
- Redis cache: 15-min TTL, stale fallback on API failure
- Quota tracking via response headers, 429 when exhausted
- Query filters: stat_type, player (partial match), book
### Phase 1 — Foundation (COMPLETE)
- Feature 1.1 — Odds API Integration
- Feature 1.2 — NBA_API Stats Wrapper (FastAPI microservice)
- Feature 1.3 — Prop Analysis Engine (6-step grading pipeline)
- Feature 1.4 — Database Schema (6 tables, RLS, triggers in Supabase)
### Feature 1.2 — NBA_API Stats Wrapper (COMPLETE)
- FastAPI microservice in nba-service/ on port 8000
- GET /stats/season-avg (24hr cache), /stats/last-n (1hr), /stats/splits (6hr), /players/search (7-day)
- PRA computed as derived stat, 0.6s rate limiting with retry
- 27 Python tests passing
### Feature 1.3 — Prop Analysis Engine (COMPLETE)
- POST /api/analyze/prop — single prop analysis with full 6-step pipeline
- POST /api/analyze/batch — multi-prop analysis for parlay scanner
- 6-step pipeline: season avg → recent form → situational splits → cross-book lines → kill conditions → grade
- Grading: composite score → A/B/C/D with confidence 30-95
- 6 kill conditions: low_minutes, small_sample, b2b_high_usage, blowout_risk, split_conflict, no_opponent_data
- Full reasoning output: step-by-step breakdown with signals
- Cross-book line comparison identifies best/worst lines
### Feature 1.4 — Database Schema (COMPLETE)
- 6 tables applied to Supabase: users, picks, scan_sessions, bets, outcomes, performance
- RLS enabled on all tables with auth.uid() policies
- 3 triggers: auto-create user, updated_at, scan count reset
- 37 schema validation tests passing
### Feature 2.1 — Parlay Scan (COMPLETE)
- POST /api/scan/parlay — full parlay analysis with auth
- Supabase JWT auth middleware (auth.getUser() verification)
- 5 correlation types: same_game_opposing, same_game_same_team, same_player_conflicting, positive_correlation, blowout_cascade
- Overall parlay grading (A/B/C/D) with correlation penalty adjustments
- Free tier: 5 scans/month with atomic scan count (race-condition safe)
- Scan 5: full analysis + personalized upgrade pitch
- Scan 6+: 403 with upgrade pitch, no analysis
- Paid tiers (analyst/desk): unlimited scans
- Upgrade pitch personalization from scan history (stat types, grades, player patterns)
- Tier recommendation: analyst for casual, desk for power users
- Database writes: picks table (per leg) + scan_sessions table (per scan)
- Founder pricing highlighted in all pitches
## Test Summary
- Node.js: 101 tests passing (unit + integration)
- Node.js: 131 tests passing (unit + integration)
- Python: 27 tests passing
- Total: 128 tests, all green
- Total: 158 tests, all green
## What's Next
- Feature 2.1 — Parlay Scan (depends: 1.3 + 1.4)
- Feature 2.2 — Real-Time Line Movement + Cascade Detection (depends: 1.1)
- Feature 1.5 — Bet Submission (depends: 1.4)
@@ -54,24 +43,25 @@ Phase 1 — Foundation (COMPLETE)
## Session Log
### Session 1 — 2026-03-21
- Made live Odds API test call, documented raw response format in DECISIONS.md
- Built Feature 1.1: oddsNormalizer.js, oddsService.js, routes/odds.js, teamMap.js, redis.js, app.js
- 28 tests (unit + integration), all passing
- Logged DECISION-001 (API response format) and DECISION-002 (credit conservation)
- Built Feature 1.1: Odds API Integration (28 tests)
- Credits used: 2 of 500 (498 remaining)
### Session 2 — 2026-03-21
- Built Feature 1.2: FastAPI microservice wrapping nba_api (27 Python tests)
- Built Feature 1.4: Full database schema SQL (37 tests), applied manually via Supabase SQL Editor
- Logged DECISION-003 (Python microservice) and DECISION-004 (Supabase Auth)
- Created startup script, Supabase client module, schema verification script
- Built Feature 1.2: FastAPI microservice (27 Python tests)
- Built Feature 1.4: Database schema (37 tests), applied to Supabase
### Session 3 — 2026-03-21
- Built Feature 1.3: Prop Analysis Engine
- propAnalyzer.js (orchestrator), grader.js, killConditions.js, nbaStatsClient.js, signals.js
- routes/analyze.js (POST /api/analyze/prop + /batch)
- Added spreads market to Odds API fetch (zero extra credits)
- 36 new tests (unit + integration)
- Logged DECISION-005 (spreads for blowout risk)
- Phase 1 Foundation is now COMPLETE
- Total: 128 tests (101 Node.js + 27 Python), all green
- Built Feature 1.3: Prop Analysis Engine (36 new tests)
- Phase 1 Foundation COMPLETE
### Session 4 — 2026-03-21
- Built Feature 2.1: Parlay Scan
- auth.js (Supabase JWT middleware)
- correlationEngine.js (5 correlation types)
- parlayGrader.js (parlay-level grading with correlation penalties)
- upgradePitch.js (personalized monetization pitch from scan history)
- parlayScanService.js (orchestrator: auth → count → analyze → correlate → grade → persist → pitch)
- routes/scan.js (POST /api/scan/parlay)
- 30 new tests (unit + integration)
- Logged DECISION-006 (auth via Supabase getUser) and DECISION-007 (atomic scan count)
- Total: 158 tests (131 Node.js + 27 Python), all green
+14
View File
@@ -94,3 +94,17 @@ Outcome level (nested under market.outcomes[]):
- Decision: Add `spreads` to the comma-separated markets list in the existing per-event API call. Zero additional API credits — the spreads data rides alongside the player prop data in the same request.
- Alternatives considered: Skip blowout_risk for now — rejected because it's a high-value kill condition that prevents bad bets on blowout games.
- Consequences: `oddsService.js` now returns a `spreads` array alongside `props`. The `extractSpreads()` function in `oddsNormalizer.js` parses game-level spread data separately from player-level props.
### DECISION-006: Auth via Supabase auth.getUser() (Feature 2.1)
- Date: 2026-03-21
- Context: Need to verify Supabase JWTs in the scan endpoint. Options were manual JWT verification with secret or using Supabase client.
- Decision: Use `supabase.auth.getUser(token)` from `@supabase/supabase-js`. Simpler, no JWT secret management, automatically validates token expiry and revocation.
- Alternatives considered: Manual JWT decode with `jsonwebtoken` library — rejected: more code, need to manage JWT secret, doesn't check revocation.
- Consequences: Auth middleware depends on Supabase API being reachable (same DNS blocker in WSL2). In production this is fine.
### DECISION-007: Atomic Scan Count Increment (Feature 2.1)
- Date: 2026-03-21
- Context: Race condition risk — two concurrent scans from same free-tier user could both read scan_count=4 and both proceed past the limit.
- Decision: Use atomic `UPDATE users SET scan_count = scan_count + 1 WHERE id = :id AND scan_count = :current_count RETURNING scan_count`. If no rows returned, another request incremented first.
- Alternatives considered: Postgres advisory locks — rejected: overkill for this use case. Optimistic concurrency with the WHERE clause is simpler and sufficient.
- Consequences: At worst, one of two concurrent requests will fail to increment and still proceed (the check happens before analysis). Acceptable for MVP — the 5-scan limit is soft, not a billing boundary.
+324
View File
@@ -0,0 +1,324 @@
# Feature 2.1 — Parlay Scan
## Overview
The flagship user-facing feature. A user submits a parlay (array of prop legs), BetonBLK grades each leg individually, checks for correlations and conflicts between legs, produces an overall parlay grade, and writes the scan to the database. Free users get 5 scans per month. At scan 5, the system fires a personalized upgrade pitch based on what it learned from scans 1-4.
## Dependencies
- Feature 1.3 — Prop Analysis Engine (`POST /api/analyze/batch`)
- Feature 1.4 — Database Schema (`scan_sessions`, `picks`, `users` tables)
## Endpoint
### POST /api/scan/parlay
Scans a parlay. Requires authentication (Supabase JWT).
**Request headers:**
```
Authorization: Bearer <supabase_jwt>
```
**Request body:**
```json
{
"legs": [
{ "player": "Nikola Jokic", "stat_type": "points", "line": 26.5, "direction": "over", "book": "draftkings" },
{ "player": "LeBron James", "stat_type": "rebounds", "line": 8.5, "direction": "over", "book": "fanduel" },
{ "player": "Anthony Davis", "stat_type": "blocks", "line": 1.5, "direction": "over", "book": "draftkings" }
]
}
```
**Response (200) — scan allowed:**
```json
{
"scan_id": "uuid",
"parlay_grade": "B",
"parlay_confidence": 68,
"correlation_flags": [
{
"type": "same_game_same_team",
"legs": [1, 2],
"detail": "LeBron James and Anthony Davis are both Lakers — usage overlap possible",
"impact": "minor_negative"
}
],
"legs": [
{
"index": 0,
"player": "Nikola Jokic",
"stat_type": "points",
"line": 26.5,
"direction": "over",
"grade": "A",
"confidence": 85,
"edge_pct": 6.0,
"kill_conditions": [],
"reasoning_summary": "Jokic averages 26.3 season, 28.1 last 10. At home vs LAL where he averages 30.5. Strong play."
},
{
"index": 1,
"...": "..."
}
],
"scan_count": 3,
"scans_remaining": 2,
"upgrade_pitch": null
}
```
**Response (200) — scan 5 (free tier limit):**
```json
{
"scan_id": "uuid",
"parlay_grade": "B",
"parlay_confidence": 68,
"correlation_flags": [],
"legs": ["...full analysis..."],
"scan_count": 5,
"scans_remaining": 0,
"upgrade_pitch": {
"hook": "You've scanned 5 parlays this month. 3 of them graded B or higher — you've got a good eye.",
"insight": "Your best edge has been player points overs. Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number.",
"cta": "Unlock unlimited scans for $14.99/mo (founder rate)",
"tier_recommended": "analyst",
"founder_price": "$14.99/mo",
"standard_price": "$19.99/mo"
}
}
```
**Response (403) — free tier exhausted (scan 6+):**
```json
{
"error": "scan_limit_reached",
"scan_count": 5,
"scans_remaining": 0,
"upgrade_pitch": {
"hook": "You've used all 5 free scans this month.",
"insight": "Upgrade to keep scanning. Your recent parlays suggest you'd benefit from the Analyst tier.",
"cta": "Unlock unlimited scans for $14.99/mo (founder rate)",
"tier_recommended": "analyst",
"founder_price": "$14.99/mo",
"standard_price": "$19.99/mo"
}
}
```
### Error Responses
| Status | When | Body |
|---|---|---|
| 400 | Missing legs, empty array, or invalid leg | `{ "error": "legs array is required with at least 2 props" }` |
| 401 | No auth token or invalid JWT | `{ "error": "Authentication required" }` |
| 403 | Free tier scan limit reached (6+) | See above — includes upgrade_pitch |
| 422 | Leg count > 12 | `{ "error": "Maximum 12 legs per parlay" }` |
| 503 | Analysis service unavailable | `{ "error": "Scan service temporarily unavailable" }` |
## Scan Count Logic
```
1. Read user.scan_count and user.tier from database
2. If tier is 'analyst' or 'desk': skip count check, proceed
3. If tier is 'free':
a. If scan_count >= 5: return 403 with upgrade_pitch (no analysis)
b. If scan_count == 4: process scan, then return result WITH upgrade_pitch (this is scan 5)
c. If scan_count < 4: process scan normally
4. After successful scan: increment user.scan_count
```
The `scan_reset_date` trigger on the users table (Feature 1.4) handles monthly reset automatically.
## Correlation Detection
The correlation engine flags conflicts between legs that reduce parlay viability. Each flag has an `impact` level: `minor_negative`, `major_negative`, or `positive`.
### Correlation Types
**1. same_game_opposing_players**
Two legs from the same game on opposing teams with the same stat type, both "over."
- Detection: same `game_time` + different teams + same `stat_type` + both `direction: "over"`
- Impact: `minor_negative` — one team likely dominates, both can't boom
- Example: Jokic over points + LeBron over points (same game)
**2. same_game_same_team**
Two legs from the same game, same team. Usage overlap risk.
- Detection: same `game_time` + same team + both player props
- Impact: `minor_negative` — minutes/touches compete
- Example: LeBron over points + AD over points (both Lakers)
**3. same_player_conflicting**
Same player, conflicting directions or stats that contradict.
- Detection: same `player` + conflicting legs (e.g., over points + under PRA)
- Impact: `major_negative` — mathematically contradictory or near-contradictory
- Example: Jokic over points + Jokic under PRA
**4. positive_correlation**
Legs that support each other.
- Detection: same player, complementary stats in same direction (e.g., over points + over PRA)
- Impact: `positive` — correlated outcomes, but note this reduces true parlay independence
- Example: Jokic over points + Jokic over PRA (points is a component of PRA)
**5. blowout_cascade**
Multiple legs from a game with a large spread — all are at risk.
- Detection: 2+ legs from a game where `|spread| > 8`
- Impact: `major_negative` — blowout risk compounds across multiple legs
### Correlation Impact on Parlay Grade
- Each `minor_negative` correlation: -0.3 from parlay composite score
- Each `major_negative` correlation: -1.0 from parlay composite score
- Each `positive` correlation: noted but no score change (informational)
- If any `major_negative` exists: parlay grade capped at B
## Overall Parlay Grading
```
1. Grade each leg via POST /api/analyze/batch
2. Run correlation detection across all legs
3. Compute parlay composite:
leg_avg = average of all leg composite scores
correlation_penalty = sum of correlation impacts
parlay_composite = leg_avg + correlation_penalty
4. Apply parlay-specific thresholds:
A: parlay_composite >= 2.5 AND no leg graded D AND no major_negative correlations
B: parlay_composite >= 1.5 AND at most 1 leg graded D
C: parlay_composite >= 0.5
D: parlay_composite < 0.5 OR 2+ legs graded D
5. Parlay confidence = average of leg confidences, adjusted:
- -5 per minor_negative correlation
- -15 per major_negative correlation
- Clamped to 30-95
```
## Upgrade Pitch Personalization
At scan 5, the system analyzes scans 1-4 to build a personalized pitch.
**Data gathered from prior scans:**
- Count of scans by parlay grade (how many A/B/C/D parlays)
- Most common stat_type scanned
- Most common players scanned
- Average leg count per parlay
- Best grade achieved
**Pitch template logic:**
```
hook: "You've scanned {total} parlays this month. {good_count} graded B or higher — {compliment}."
compliment options:
- "you've got a good eye" (if good_count >= 3)
- "you're getting sharper" (if good_count >= 2)
- "BetonBLK is helping you filter" (if good_count >= 1)
- "let's find better edges together" (if good_count == 0)
insight: "Your best edge has been {top_stat_type} {top_direction}s. {tier_benefit}."
tier_benefit:
- analyst: "Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number."
- desk: "Desk tier adds full bet tracking, ROI analytics, and priority cascade alerts."
cta: "Unlock unlimited scans for {founder_price} (founder rate)"
tier_recommended:
- "analyst" if avg legs <= 4 (casual bettor)
- "desk" if avg legs > 4 OR user has scanned 5+ different players (power user)
```
## Database Writes
### On successful scan:
1. Insert one row per leg into `picks` table:
```sql
INSERT INTO picks (user_id, player, stat_type, line, book, direction, grade, edge_pct, reasoning, kill_conditions, confidence)
```
2. Insert one row into `scan_sessions`:
```sql
INSERT INTO scan_sessions (user_id, legs, final_grade, kill_conditions, correlation_notes)
```
Where `legs` = array of pick UUIDs from step 1, `correlation_notes` = JSON string of correlation_flags.
3. Increment `users.scan_count`:
```sql
UPDATE users SET scan_count = scan_count + 1 WHERE id = :user_id
```
## Service Architecture
```
src/
├── services/
│ ├── parlayScanService.js # Orchestrator: scan count check → analyze → correlate → grade → persist
│ ├── correlationEngine.js # Detects correlations between legs
│ ├── parlayGrader.js # Overall parlay grade from leg grades + correlations
│ └── upgradePitch.js # Personalized pitch generator from scan history
├── routes/
│ └── scan.js # POST /api/scan/parlay
└── middleware/
└── auth.js # Supabase JWT verification middleware
```
- **parlayScanService.js** — Main orchestrator. Checks scan count, calls analyze/batch, runs correlation + grading, writes to DB, generates pitch if applicable.
- **correlationEngine.js** — Pure function. Takes array of analyzed legs + odds data, returns correlation_flags[].
- **parlayGrader.js** — Pure function. Takes leg grades + correlation flags, returns parlay grade + confidence.
- **upgradePitch.js** — Reads scan history from DB, generates personalized pitch.
- **auth.js** — Middleware that verifies Supabase JWT and attaches `req.user` with `{ id, email, tier }`.
## Acceptance Criteria
1. `POST /api/scan/parlay` accepts 2-12 legs and returns complete parlay analysis
2. Each leg includes individual grade, confidence, edge_pct, kill conditions, and reasoning summary
3. Correlation detection correctly identifies all 5 correlation types
4. Correlation impacts adjust parlay composite score (minor: -0.3, major: -1.0)
5. Overall parlay grade follows the grading rules (A/B/C/D with correlation constraints)
6. Free tier users get exactly 5 scans per month; scan 6+ returns 403
7. Paid tier users (analyst, desk) have unlimited scans
8. Scan 5 returns full analysis WITH a personalized upgrade pitch
9. Upgrade pitch references user's actual scan history (stat types, grades, player patterns)
10. Each successful scan writes picks to `picks` table and session to `scan_sessions` table
11. `users.scan_count` increments after each successful scan
12. Auth middleware rejects requests without valid Supabase JWT (401)
13. Returns 400 for fewer than 2 legs or missing required fields
14. Returns 422 for more than 12 legs
## Test Plan
### Unit Tests (correlationEngine.js)
- Detects same_game_opposing_players (same game, diff teams, same stat, both over)
- Detects same_game_same_team (same game, same team)
- Detects same_player_conflicting (same player, contradictory legs)
- Detects positive_correlation (same player, complementary stats)
- Detects blowout_cascade (2+ legs from high-spread game)
- Returns empty array when no correlations exist
- Handles single-leg edge case (no correlations possible with 1 leg)
### Unit Tests (parlayGrader.js)
- Grade A: high composite, no D legs, no major_negative correlations
- Grade B: moderate composite with at most 1 D leg
- Grade C: low but positive composite
- Grade D: negative composite or 2+ D legs
- Major_negative caps grade at B
- Correlation penalties reduce composite correctly
- Confidence averages legs and adjusts for correlations
### Unit Tests (upgradePitch.js)
- Generates pitch with correct scan count summary
- Identifies most common stat type from scan history
- Recommends analyst for casual bettors (avg <= 4 legs)
- Recommends desk for power users (avg > 4 legs)
- Handles edge case: no prior scans (scan 1 as free tier limit would be unusual)
### Integration Tests (routes/scan.js)
- Full scan: 3-leg parlay → complete response with grades, correlations, scan_count
- Scan count increments after each successful scan
- Scan 5: returns full analysis plus upgrade_pitch
- Scan 6+: returns 403 with upgrade_pitch (no analysis)
- Paid user: unlimited scans, no pitch
- Auth required: no token → 401
- Invalid legs: empty array → 400, >12 legs → 422
- Correlation: same-game same-team legs → flagged in response
- Database: scan_sessions row created with correct legs array
- Database: picks rows created for each leg
## Open Questions
- **Auth middleware implementation:** Supabase JWT verification needs the project's JWT secret. We have the service key in .env. The anon key signs JWTs. Need to verify JWT using `@supabase/supabase-js` auth helpers or the JWT secret directly. Decide during implementation.
- **Scan count race condition:** Two concurrent scans from the same user could both read scan_count=4 and both proceed. Use a Postgres advisory lock or `UPDATE ... WHERE scan_count < 5 RETURNING scan_count` for atomic check-and-increment.
+2
View File
@@ -2,10 +2,12 @@ require('dotenv').config();
const express = require('express');
const oddsRoutes = require('./routes/odds');
const analyzeRoutes = require('./routes/analyze');
const scanRoutes = require('./routes/scan');
const app = express();
app.use(express.json());
app.use('/api/odds', oddsRoutes);
app.use('/api/analyze', analyzeRoutes);
app.use('/api/scan', scanRoutes);
module.exports = app;
+32
View File
@@ -0,0 +1,32 @@
const { getSupabaseServiceClient } = require('../utils/supabase');
async function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.slice(7);
const supabase = getSupabaseServiceClient();
const { data: { user }, error } = await supabase.auth.getUser(token);
if (error || !user) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
// Fetch user profile from our users table
const { data: profile, error: profileError } = await supabase
.from('users')
.select('id, email, tier, scan_count, scan_reset_date, founder_status')
.eq('id', user.id)
.single();
if (profileError || !profile) {
return res.status(401).json({ error: 'User profile not found' });
}
req.user = profile;
next();
}
module.exports = { requireAuth };
+66
View File
@@ -0,0 +1,66 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { scanParlay } = require('../services/parlayScanService');
const router = express.Router();
const VALID_STAT_TYPES = new Set([
'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers',
]);
const VALID_DIRECTIONS = new Set(['over', 'under']);
function validateLegs(legs) {
if (!Array.isArray(legs) || legs.length < 2) {
return 'legs array is required with at least 2 props';
}
if (legs.length > 12) {
return 'max_legs_exceeded';
}
for (let i = 0; i < legs.length; i++) {
const leg = legs[i];
if (!leg.player) return `leg ${i}: player is required`;
if (!leg.stat_type) return `leg ${i}: stat_type is required`;
if (leg.stat_type && !VALID_STAT_TYPES.has(leg.stat_type)) {
return `leg ${i}: Invalid stat_type: ${leg.stat_type}`;
}
if (leg.line == null) return `leg ${i}: line is required`;
if (!leg.direction) return `leg ${i}: direction is required`;
if (leg.direction && !VALID_DIRECTIONS.has(leg.direction)) {
return `leg ${i}: Invalid direction: ${leg.direction}`;
}
}
return null;
}
router.post('/parlay', requireAuth, async (req, res) => {
const { legs } = req.body;
const validationError = validateLegs(legs);
if (validationError === 'max_legs_exceeded') {
return res.status(422).json({ error: 'Maximum 12 legs per parlay' });
}
if (validationError) {
return res.status(400).json({ error: validationError });
}
try {
const result = await scanParlay(req.user, legs);
if (result.blocked) {
return res.status(403).json({
error: 'scan_limit_reached',
scan_count: result.scan_count,
scans_remaining: result.scans_remaining,
upgrade_pitch: result.upgrade_pitch,
});
}
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Scan error:', err.message);
return res.status(503).json({ error: 'Scan service temporarily unavailable' });
}
});
module.exports = router;
+126
View File
@@ -0,0 +1,126 @@
function detectCorrelations(analyzedLegs, spreads) {
const flags = [];
for (let i = 0; i < analyzedLegs.length; i++) {
for (let j = i + 1; j < analyzedLegs.length; j++) {
const a = analyzedLegs[i];
const b = analyzedLegs[j];
const aGame = a.reasoning?.steps?.season_avg ? getGameKey(a) : null;
const bGame = b.reasoning?.steps?.season_avg ? getGameKey(b) : null;
const sameGame = aGame && bGame && aGame === bGame;
const aTeam = a._team;
const bTeam = b._team;
// 1. same_player_conflicting
if (a.player.toLowerCase() === b.player.toLowerCase()) {
if (isConflicting(a, b)) {
flags.push({
type: 'same_player_conflicting',
legs: [i, j],
detail: `${a.player}: ${a.stat_type} ${a.direction} conflicts with ${b.stat_type} ${b.direction}`,
impact: 'major_negative',
});
} else {
// 4. positive_correlation (same player, complementary)
flags.push({
type: 'positive_correlation',
legs: [i, j],
detail: `${a.player}: ${a.stat_type} ${a.direction} + ${b.stat_type} ${b.direction} are correlated`,
impact: 'positive',
});
}
continue;
}
if (!sameGame) continue;
// 2. same_game_same_team
if (aTeam && bTeam && aTeam === bTeam) {
flags.push({
type: 'same_game_same_team',
legs: [i, j],
detail: `${a.player} and ${b.player} are both ${aTeam} — usage overlap possible`,
impact: 'minor_negative',
});
continue;
}
// 3. same_game_opposing_players
if (a.stat_type === b.stat_type && a.direction === 'over' && b.direction === 'over') {
flags.push({
type: 'same_game_opposing_players',
legs: [i, j],
detail: `${a.player} and ${b.player} in same game, both ${a.stat_type} overs`,
impact: 'minor_negative',
});
}
}
}
// 5. blowout_cascade — 2+ legs from a high-spread game
const gameLegs = groupByGame(analyzedLegs);
for (const [gameKey, indices] of Object.entries(gameLegs)) {
if (indices.length < 2) continue;
const gameSpread = findSpreadForGame(analyzedLegs[indices[0]], spreads);
if (gameSpread != null && Math.abs(gameSpread) > 8) {
flags.push({
type: 'blowout_cascade',
legs: indices,
detail: `${indices.length} legs from a game with ${gameSpread > 0 ? '+' : ''}${gameSpread} spread — blowout risk compounds`,
impact: 'major_negative',
});
}
}
return flags;
}
function getGameKey(leg) {
// Use home_team + away_team from reasoning to identify the game
const sit = leg.reasoning?.steps?.situational;
const lineCmp = leg.reasoning?.steps?.line_comparison;
// Fallback: use the first line's game context
// We'll use _gameTime attached by the scan service
return leg._gameTime || null;
}
function isConflicting(a, b) {
// Same player, opposite directions on related stats
if (a.direction !== b.direction) return true;
// Over points + under PRA (points is component of PRA)
const complementary = [
['points', 'pra'], ['rebounds', 'pra'], ['assists', 'pra'],
];
for (const [s1, s2] of complementary) {
if ((a.stat_type === s1 && b.stat_type === s2) || (a.stat_type === s2 && b.stat_type === s1)) {
if (a.direction !== b.direction) return true;
}
}
return false;
}
function groupByGame(legs) {
const groups = {};
for (let i = 0; i < legs.length; i++) {
const key = legs[i]._gameTime;
if (!key) continue;
if (!groups[key]) groups[key] = [];
groups[key].push(i);
}
return groups;
}
function findSpreadForGame(leg, spreads) {
if (!spreads || !leg._team) return null;
const spread = spreads.find((s) =>
s.home_team === leg._team || s.away_team === leg._team
);
if (!spread) return null;
return spread.home_spread;
}
module.exports = { detectCorrelations };
+63
View File
@@ -0,0 +1,63 @@
function gradeParlayFromLegs(legResults, correlationFlags) {
if (legResults.length === 0) {
return { grade: 'D', confidence: 30, composite: 0 };
}
// Extract leg composites and grades
const legComposites = legResults.map((l) => l._composite || 0);
const legGrades = legResults.map((l) => l.grade);
const legConfidences = legResults.map((l) => l.confidence);
const dCount = legGrades.filter((g) => g === 'D').length;
// Average of leg composites
const legAvg = legComposites.reduce((a, b) => a + b, 0) / legComposites.length;
// Correlation penalties
let correlationPenalty = 0;
let hasMajorNegative = false;
for (const flag of correlationFlags) {
if (flag.impact === 'minor_negative') correlationPenalty -= 0.3;
if (flag.impact === 'major_negative') {
correlationPenalty -= 1.0;
hasMajorNegative = true;
}
}
const parlayComposite = legAvg + correlationPenalty;
// Grade assignment
let grade;
if (parlayComposite >= 2.5 && dCount === 0 && !hasMajorNegative) {
grade = 'A';
} else if (parlayComposite >= 1.5 && dCount <= 1) {
grade = 'B';
} else if (parlayComposite >= 0.5) {
grade = 'C';
} else {
grade = 'D';
}
// 2+ D legs forces grade D
if (dCount >= 2) grade = 'D';
// Major negative caps at B
if (hasMajorNegative && (grade === 'A')) {
grade = 'B';
}
// Confidence: average of legs, adjusted for correlations
let confidence = legConfidences.reduce((a, b) => a + b, 0) / legConfidences.length;
for (const flag of correlationFlags) {
if (flag.impact === 'minor_negative') confidence -= 5;
if (flag.impact === 'major_negative') confidence -= 15;
}
confidence = Math.max(30, Math.min(95, Math.round(confidence)));
return {
grade,
confidence,
composite: Math.round(parlayComposite * 100) / 100,
};
}
module.exports = { gradeParlayFromLegs };
+173
View File
@@ -0,0 +1,173 @@
const { analyzeProp } = require('./propAnalyzer');
const { getOdds } = require('./oddsService');
const { detectCorrelations } = require('./correlationEngine');
const { gradeParlayFromLegs } = require('./parlayGrader');
const { generateUpgradePitch } = require('./upgradePitch');
const { getSupabaseServiceClient } = require('../utils/supabase');
async function scanParlay(user, legs) {
const supabase = getSupabaseServiceClient();
const isFree = user.tier === 'free';
// Scan count check (atomic for free tier)
if (isFree) {
if (user.scan_count >= 5) {
// Already exhausted — return 403 with pitch
const pitch = await generateUpgradePitch(supabase, user.id, null);
return {
blocked: true,
scan_count: user.scan_count,
scans_remaining: 0,
upgrade_pitch: pitch,
};
}
}
// Analyze all legs
const legResults = [];
for (const leg of legs) {
const result = await analyzeProp(leg);
legResults.push(result);
}
// Fetch odds data for correlation detection (spreads, game context)
let spreads = [];
try {
const oddsData = await getOdds('nba');
spreads = oddsData.spreads || [];
// Attach game context to leg results for correlation detection
for (const leg of legResults) {
const matchingProps = (oddsData.props || []).filter(
(p) => p.player.toLowerCase().includes(leg.player.toLowerCase())
);
if (matchingProps.length > 0) {
const prop = matchingProps[0];
leg._gameTime = prop.game_time;
// Resolve team from season avg
const seasonStep = leg.reasoning?.steps?.season_avg;
const team = leg._resolvedTeam || null;
// Use the team from the analysis context
if (leg.reasoning?.steps?.situational?.home_away?.context === 'home') {
leg._team = prop.home_team;
} else if (leg.reasoning?.steps?.situational?.home_away?.context === 'away') {
leg._team = prop.away_team;
}
}
}
} catch (_) {
// Correlation detection is best-effort
}
// Detect correlations
const correlationFlags = detectCorrelations(legResults, spreads);
// Grade the parlay
// Attach composite scores from individual analyses for parlay grading
for (const leg of legResults) {
// Reconstruct composite from the reasoning steps
const steps = leg.reasoning?.steps;
if (steps) {
const seasonDelta = steps.season_avg?.vs_line || 0;
const recentDelta = steps.recent_form?.vs_line || 0;
leg._composite = (Math.abs(seasonDelta) + Math.abs(recentDelta)) / 2;
} else {
leg._composite = 0;
}
}
const { grade: parlayGrade, confidence: parlayConfidence } = gradeParlayFromLegs(
legResults,
correlationFlags
);
// Write to database
const pickIds = [];
for (const leg of legResults) {
const { data: pick, error } = await supabase
.from('picks')
.insert({
user_id: user.id,
player: leg.player,
stat_type: leg.stat_type,
line: leg.line,
book: leg.book || 'unknown',
direction: leg.direction,
grade: leg.grade,
edge_pct: leg.edge_pct,
reasoning: leg.reasoning?.summary || '',
kill_conditions: (leg.kill_conditions_triggered || []).map((k) => k.code),
confidence: leg.confidence,
})
.select('id')
.single();
if (pick) pickIds.push(pick.id);
}
// Write scan session
const { data: session } = await supabase
.from('scan_sessions')
.insert({
user_id: user.id,
legs: pickIds,
final_grade: parlayGrade,
kill_conditions: correlationFlags
.filter((f) => f.impact !== 'positive')
.map((f) => f.type),
correlation_notes: JSON.stringify(correlationFlags),
})
.select('id')
.single();
// Atomic scan count increment for free tier
let newScanCount = user.scan_count;
if (isFree) {
const { data: updated } = await supabase
.from('users')
.update({ scan_count: user.scan_count + 1 })
.eq('id', user.id)
.eq('scan_count', user.scan_count)
.select('scan_count')
.single();
newScanCount = updated?.scan_count ?? user.scan_count + 1;
}
// Build response legs (stripped of internal fields)
const responseLegs = legResults.map((leg, i) => ({
index: i,
player: leg.player,
stat_type: leg.stat_type,
line: leg.line,
direction: leg.direction,
grade: leg.grade,
confidence: leg.confidence,
edge_pct: leg.edge_pct,
kill_conditions: leg.kill_conditions_triggered || [],
reasoning_summary: leg.reasoning?.summary || '',
}));
// Generate upgrade pitch at scan 5
let upgradePitch = null;
if (isFree && newScanCount >= 5) {
upgradePitch = await generateUpgradePitch(supabase, user.id, {
grade: parlayGrade,
legs: responseLegs,
});
}
return {
blocked: false,
scan_id: session?.id || null,
parlay_grade: parlayGrade,
parlay_confidence: parlayConfidence,
correlation_flags: correlationFlags,
legs: responseLegs,
scan_count: newScanCount,
scans_remaining: isFree ? Math.max(0, 5 - newScanCount) : null,
upgrade_pitch: upgradePitch,
};
}
module.exports = { scanParlay };
+86
View File
@@ -0,0 +1,86 @@
async function generateUpgradePitch(supabase, userId, currentScanResults) {
// Fetch prior scan sessions + picks
const { data: sessions } = await supabase
.from('scan_sessions')
.select('id, final_grade, legs, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10);
const { data: picks } = await supabase
.from('picks')
.select('stat_type, direction, grade, player')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(50);
const allPicks = picks || [];
const allSessions = sessions || [];
const totalScans = allSessions.length + 1; // +1 for current
// Count good grades (A or B)
const priorGrades = allSessions.map((s) => s.final_grade).filter(Boolean);
if (currentScanResults?.grade) priorGrades.push(currentScanResults.grade);
const goodCount = priorGrades.filter((g) => g === 'A' || g === 'B').length;
// Most common stat type
const statCounts = {};
for (const pick of allPicks) {
statCounts[pick.stat_type] = (statCounts[pick.stat_type] || 0) + 1;
}
// Include current scan legs
if (currentScanResults?.legs) {
for (const leg of currentScanResults.legs) {
const st = leg.stat_type;
statCounts[st] = (statCounts[st] || 0) + 1;
}
}
const topStatType = Object.entries(statCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'props';
// Most common direction for top stat
const dirCounts = { over: 0, under: 0 };
for (const pick of allPicks) {
if (pick.stat_type === topStatType) {
dirCounts[pick.direction] = (dirCounts[pick.direction] || 0) + 1;
}
}
const topDirection = dirCounts.over >= dirCounts.under ? 'over' : 'under';
// Average leg count
const legCounts = allSessions.map((s) => (s.legs || []).length);
if (currentScanResults?.legs) legCounts.push(currentScanResults.legs.length);
const avgLegs = legCounts.length > 0
? legCounts.reduce((a, b) => a + b, 0) / legCounts.length
: 3;
// Unique players scanned
const uniquePlayers = new Set(allPicks.map((p) => p.player));
// Compliment
let compliment;
if (goodCount >= 3) compliment = "you've got a good eye";
else if (goodCount >= 2) compliment = "you're getting sharper";
else if (goodCount >= 1) compliment = "BetonBLK is helping you filter";
else compliment = "let's find better edges together";
// Tier recommendation
const tierRecommended = (avgLegs > 4 || uniquePlayers.size >= 5) ? 'desk' : 'analyst';
const tierBenefit = tierRecommended === 'desk'
? 'Desk tier adds full bet tracking, ROI analytics, and priority cascade alerts.'
: 'Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number.';
const founderPrice = tierRecommended === 'desk' ? '$34.99/mo' : '$14.99/mo';
const standardPrice = tierRecommended === 'desk' ? '$49.99/mo' : '$19.99/mo';
return {
hook: `You've scanned ${totalScans} parlays this month. ${goodCount} graded B or higher — ${compliment}.`,
insight: `Your best edge has been ${topStatType} ${topDirection}s. ${tierBenefit}`,
cta: `Unlock unlimited scans for ${founderPrice} (founder rate)`,
tier_recommended: tierRecommended,
founder_price: founderPrice,
standard_price: standardPrice,
};
}
module.exports = { generateUpgradePitch };
+351
View File
@@ -0,0 +1,351 @@
const request = require('supertest');
// Mock Redis
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
hset: jest.fn(),
hgetall: jest.fn(),
expire: jest.fn(),
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
}));
// Mock Supabase
const mockSupabaseFrom = jest.fn();
const mockSupabaseAuth = {
getUser: jest.fn(),
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
}));
// Mock axios
jest.mock('axios');
const axios = require('axios');
process.env.ODDS_API_KEY = 'test-key';
process.env.NBA_SERVICE_URL = 'http://localhost:8000';
const app = require('../../src/app');
// --- Mock Data ---
const MOCK_USER_FREE = {
id: 'user-free-1',
email: 'free@test.com',
tier: 'free',
scan_count: 0,
scan_reset_date: '2026-04-01',
founder_status: false,
};
const MOCK_USER_PAID = {
id: 'user-paid-1',
email: 'paid@test.com',
tier: 'analyst',
scan_count: 100,
scan_reset_date: '2026-04-01',
founder_status: true,
};
const MOCK_ODDS_EVENTS = [
{ id: 'game-1', sport_key: 'basketball_nba', home_team: 'Denver Nuggets', away_team: 'Los Angeles Lakers', commence_time: '2026-03-21T19:00:00Z' },
];
const MOCK_ODDS_RESPONSE = {
...MOCK_ODDS_EVENTS[0],
bookmakers: [
{
key: 'draftkings', title: 'DraftKings',
markets: [
{
key: 'player_points', last_update: '2026-03-21T14:28:00Z',
outcomes: [
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
{ name: 'Over', description: 'LeBron James', price: -115, point: 25.5 },
{ name: 'Under', description: 'LeBron James', price: -105, point: 25.5 },
],
},
{
key: 'player_rebounds', last_update: '2026-03-21T14:28:00Z',
outcomes: [
{ name: 'Over', description: 'Nikola Jokic', price: -130, point: 12.5 },
{ name: 'Under', description: 'Nikola Jokic', price: 110, point: 12.5 },
],
},
{
key: 'spreads', last_update: '2026-03-21T14:28:00Z',
outcomes: [
{ name: 'Denver Nuggets', price: -110, point: -5.5 },
{ name: 'Los Angeles Lakers', price: -110, point: 5.5 },
],
},
],
},
],
};
const MOCK_SEASON_AVG = {
player: 'Nikola Jokic', player_id: 203999, team: 'DEN', season: '2025-26', source: 'cache',
stats: { points: 26.3, rebounds: 12.4, assists: 9.1, threes: 1.1, blocks: 0.7, steals: 1.4, pra: 47.8, turnovers: 3.2, games_played: 65, minutes: 34.2 },
};
const MOCK_LAST_N = {
player: 'Nikola Jokic', player_id: 203999, team: 'DEN', last_n: 10, source: 'cache',
stats: { points: 28.1, rebounds: 13.0, assists: 10.2, threes: 1.3, blocks: 0.8, steals: 1.5, pra: 51.3, turnovers: 2.9, games_played: 10, minutes: 35.1 },
};
const MOCK_SPLITS = {
splits: { home: { avg: 27.8, games: 33 }, away: { avg: 24.9, games: 32 } },
};
const MOCK_REST = {
splits: { b2b: { avg: 23.1, games: 8 }, '1_day_rest': { avg: 26.5, games: 40 }, '2_plus_days_rest': { avg: 28.2, games: 17 } },
};
const MOCK_VS_TEAM = {
splits: { vs_opponent: { avg: 30.5, games: 3 }, vs_all_others: { avg: 25.8, games: 62 } },
};
const API_HEADERS = { 'x-requests-remaining': '488', 'x-requests-used': '12' };
function setupAllMocks(user = MOCK_USER_FREE) {
// Auth
mockSupabaseAuth.getUser.mockResolvedValue({
data: { user: { id: user.id, email: user.email } },
error: null,
});
// Supabase from() calls
mockSupabaseFrom.mockImplementation((table) => {
if (table === 'users') {
return {
select: () => ({
eq: () => ({
single: () => Promise.resolve({ data: user, error: null }),
}),
}),
update: () => ({
eq: (col, val) => ({
eq: () => ({
select: () => ({
single: () => Promise.resolve({ data: { scan_count: user.scan_count + 1 }, error: null }),
}),
}),
}),
}),
};
}
if (table === 'picks') {
return {
insert: () => ({
select: () => ({
single: () => Promise.resolve({ data: { id: 'pick-' + Math.random().toString(36).slice(2) }, error: null }),
}),
}),
select: () => ({
eq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: [], error: null }),
}),
}),
}),
};
}
if (table === 'scan_sessions') {
return {
insert: () => ({
select: () => ({
single: () => Promise.resolve({ data: { id: 'session-1' }, error: null }),
}),
}),
select: () => ({
eq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: [], error: null }),
}),
}),
}),
};
}
// Default for other tables (e.g., scan history queries in upgradePitch)
return {
select: () => ({
eq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: [], error: null }),
}),
single: () => Promise.resolve({ data: null, error: null }),
}),
}),
};
});
// Redis
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockRedis.hset.mockResolvedValue(1);
mockRedis.hgetall.mockResolvedValue({});
mockRedis.expire.mockResolvedValue(1);
// Axios (Odds API + NBA stats service)
axios.get.mockImplementation((url, config) => {
if (url.includes('the-odds-api.com') && !url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
}
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_RESPONSE, headers: API_HEADERS });
}
if (url.includes('localhost:8000/stats/season-avg')) {
return Promise.resolve({ data: MOCK_SEASON_AVG });
}
if (url.includes('localhost:8000/stats/last-n')) {
return Promise.resolve({ data: MOCK_LAST_N });
}
if (url.includes('localhost:8000/stats/splits')) {
const st = config?.params?.split_type;
if (st === 'rest_days') return Promise.resolve({ data: MOCK_REST });
if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
return Promise.resolve({ data: MOCK_SPLITS });
}
return Promise.reject(new Error(`Unmocked: ${url}`));
});
}
const VALID_PARLAY = {
legs: [
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' },
{ player: 'LeBron James', stat_type: 'rebounds', line: 8.5, direction: 'over', book: 'fanduel' },
],
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /api/scan/parlay', () => {
test('returns 401 without auth token', async () => {
const res = await request(app)
.post('/api/scan/parlay')
.send(VALID_PARLAY)
.expect(401);
expect(res.body.error).toContain('Authentication required');
});
test('returns 401 with invalid token', async () => {
mockSupabaseAuth.getUser.mockResolvedValue({ data: { user: null }, error: { message: 'invalid' } });
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer bad-token')
.send(VALID_PARLAY)
.expect(401);
expect(res.body.error).toContain('Invalid');
});
test('returns 400 for fewer than 2 legs', async () => {
setupAllMocks();
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send({ legs: [{ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over' }] })
.expect(400);
expect(res.body.error).toContain('at least 2');
});
test('returns 422 for more than 12 legs', async () => {
setupAllMocks();
const legs = Array(13).fill({ player: 'Jokic', stat_type: 'points', line: 26.5, direction: 'over' });
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send({ legs })
.expect(422);
expect(res.body.error).toContain('12 legs');
});
test('full scan: returns complete response with grades + correlations', async () => {
setupAllMocks();
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send(VALID_PARLAY)
.expect(200);
expect(res.body.scan_id).toBeDefined();
expect(res.body.parlay_grade).toMatch(/^[ABCD]$/);
expect(typeof res.body.parlay_confidence).toBe('number');
expect(Array.isArray(res.body.correlation_flags)).toBe(true);
expect(Array.isArray(res.body.legs)).toBe(true);
expect(res.body.legs).toHaveLength(2);
expect(res.body.legs[0]).toHaveProperty('grade');
expect(res.body.legs[0]).toHaveProperty('confidence');
expect(res.body.legs[0]).toHaveProperty('edge_pct');
expect(res.body.legs[0]).toHaveProperty('reasoning_summary');
expect(typeof res.body.scan_count).toBe('number');
});
test('paid user: unlimited scans, no pitch', async () => {
setupAllMocks(MOCK_USER_PAID);
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send(VALID_PARLAY)
.expect(200);
expect(res.body.upgrade_pitch).toBeNull();
expect(res.body.scans_remaining).toBeNull();
});
test('scan 5: returns analysis WITH upgrade pitch', async () => {
const user5 = { ...MOCK_USER_FREE, scan_count: 4 };
setupAllMocks(user5);
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send(VALID_PARLAY)
.expect(200);
expect(res.body.upgrade_pitch).not.toBeNull();
expect(res.body.upgrade_pitch.hook).toBeDefined();
expect(res.body.upgrade_pitch.cta).toContain('founder rate');
expect(res.body.upgrade_pitch.tier_recommended).toBeDefined();
expect(res.body.scans_remaining).toBe(0);
});
test('scan 6+: returns 403 with upgrade pitch, no analysis', async () => {
const user6 = { ...MOCK_USER_FREE, scan_count: 5 };
setupAllMocks(user6);
const res = await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send(VALID_PARLAY)
.expect(403);
expect(res.body.error).toBe('scan_limit_reached');
expect(res.body.upgrade_pitch).toBeDefined();
expect(res.body.upgrade_pitch.cta).toContain('founder rate');
// No legs in the response since analysis was blocked
expect(res.body.legs).toBeUndefined();
});
test('database: picks and scan_sessions are written', async () => {
setupAllMocks();
await request(app)
.post('/api/scan/parlay')
.set('Authorization', 'Bearer valid-token')
.send(VALID_PARLAY)
.expect(200);
// Verify picks insert was called (2 legs = 2 picks)
const pickInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'picks');
expect(pickInserts.length).toBe(2);
// Verify scan_sessions insert was called once
const sessionInserts = mockSupabaseFrom.mock.calls.filter(([t]) => t === 'scan_sessions');
expect(sessionInserts.length).toBeGreaterThanOrEqual(1);
});
});
+102
View File
@@ -0,0 +1,102 @@
const { detectCorrelations } = require('../../src/services/correlationEngine');
function makeLeg(overrides = {}) {
return {
player: 'Nikola Jokic',
stat_type: 'points',
direction: 'over',
line: 26.5,
grade: 'B',
confidence: 70,
_team: 'DEN',
_gameTime: '2026-03-21T19:00:00Z',
reasoning: { steps: { season_avg: { value: 26.3 } } },
...overrides,
};
}
describe('correlationEngine', () => {
test('returns empty array when no correlations exist', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', _team: 'DEN', _gameTime: '2026-03-21T19:00:00Z' }),
makeLeg({ player: 'Jayson Tatum', _team: 'BOS', _gameTime: '2026-03-21T20:00:00Z' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toEqual([]);
});
test('detects same_game_opposing_players', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over', _team: 'DEN', _gameTime: 'game1' }),
makeLeg({ player: 'LeBron James', stat_type: 'points', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('same_game_opposing_players');
expect(flags[0].impact).toBe('minor_negative');
});
test('detects same_game_same_team', () => {
const legs = [
makeLeg({ player: 'LeBron James', _team: 'LAL', _gameTime: 'game1' }),
makeLeg({ player: 'Anthony Davis', _team: 'LAL', _gameTime: 'game1' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('same_game_same_team');
expect(flags[0].impact).toBe('minor_negative');
});
test('detects same_player_conflicting (opposite directions)', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over' }),
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'under' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('same_player_conflicting');
expect(flags[0].impact).toBe('major_negative');
});
test('detects positive_correlation (same player, complementary same direction)', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over' }),
makeLeg({ player: 'Nikola Jokic', stat_type: 'rebounds', direction: 'over' }),
];
const flags = detectCorrelations(legs, []);
expect(flags).toHaveLength(1);
expect(flags[0].type).toBe('positive_correlation');
expect(flags[0].impact).toBe('positive');
});
test('detects blowout_cascade (2+ legs, high spread)', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', _team: 'DEN', _gameTime: 'game1' }),
makeLeg({ player: 'LeBron James', _team: 'LAL', _gameTime: 'game1', stat_type: 'rebounds' }),
];
const spreads = [
{ home_team: 'DEN', away_team: 'LAL', game_time: 'game1', home_spread: -12, book: 'draftkings' },
];
const flags = detectCorrelations(legs, spreads);
const cascade = flags.find((f) => f.type === 'blowout_cascade');
expect(cascade).toBeDefined();
expect(cascade.impact).toBe('major_negative');
});
test('handles single leg (no correlations possible)', () => {
const legs = [makeLeg()];
const flags = detectCorrelations(legs, []);
expect(flags).toEqual([]);
});
test('multiple correlations can fire simultaneously', () => {
const legs = [
makeLeg({ player: 'Nikola Jokic', stat_type: 'points', direction: 'over', _team: 'DEN', _gameTime: 'game1' }),
makeLeg({ player: 'LeBron James', stat_type: 'points', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
makeLeg({ player: 'Anthony Davis', stat_type: 'rebounds', direction: 'over', _team: 'LAL', _gameTime: 'game1' }),
];
const flags = detectCorrelations(legs, []);
// Jokic vs LeBron = same_game_opposing_players, LeBron vs AD = same_game_same_team
expect(flags.length).toBeGreaterThanOrEqual(2);
});
});
+74
View File
@@ -0,0 +1,74 @@
const { gradeParlayFromLegs } = require('../../src/services/parlayGrader');
function makeLeg(grade, confidence, composite) {
return { grade, confidence, _composite: composite };
}
describe('parlayGrader', () => {
test('grade A: high composite, no D legs, no major_negative', () => {
const legs = [makeLeg('A', 85, 3.5), makeLeg('A', 90, 3.0), makeLeg('B', 72, 2.0)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('A');
expect(result.confidence).toBeGreaterThanOrEqual(80);
});
test('grade B: moderate composite, at most 1 D leg', () => {
const legs = [makeLeg('B', 70, 2.5), makeLeg('B', 65, 2.0), makeLeg('D', 35, 0.3)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('B');
});
test('grade C: low positive composite', () => {
const legs = [makeLeg('C', 55, 0.8), makeLeg('C', 50, 0.6)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('C');
});
test('grade D: negative composite', () => {
const legs = [makeLeg('D', 35, 0.1), makeLeg('D', 30, -0.5)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('D');
});
test('2+ D legs forces grade D', () => {
const legs = [makeLeg('A', 90, 4.0), makeLeg('D', 35, 0.2), makeLeg('D', 30, 0.1)];
const result = gradeParlayFromLegs(legs, []);
expect(result.grade).toBe('D');
});
test('major_negative caps grade at B', () => {
const legs = [makeLeg('A', 90, 4.0), makeLeg('A', 85, 3.5)];
const flags = [{ type: 'same_player_conflicting', legs: [0, 1], impact: 'major_negative' }];
const result = gradeParlayFromLegs(legs, flags);
expect(['B', 'C', 'D']).toContain(result.grade);
});
test('minor_negative reduces composite by 0.3 each', () => {
const legs = [makeLeg('B', 70, 2.0), makeLeg('B', 68, 1.8)];
const withoutFlags = gradeParlayFromLegs(legs, []);
const withFlags = gradeParlayFromLegs(legs, [
{ type: 'same_game_same_team', legs: [0, 1], impact: 'minor_negative' },
]);
expect(withFlags.composite).toBeCloseTo(withoutFlags.composite - 0.3, 1);
});
test('confidence adjusted: -5 per minor, -15 per major', () => {
const legs = [makeLeg('B', 80, 2.0), makeLeg('B', 80, 2.0)];
const noFlags = gradeParlayFromLegs(legs, []);
const minorFlag = gradeParlayFromLegs(legs, [
{ type: 'test', impact: 'minor_negative' },
]);
const majorFlag = gradeParlayFromLegs(legs, [
{ type: 'test', impact: 'major_negative' },
]);
expect(minorFlag.confidence).toBe(noFlags.confidence - 5);
expect(majorFlag.confidence).toBe(noFlags.confidence - 15);
});
test('confidence clamped to 30-95', () => {
const legs = [makeLeg('D', 35, 0.1)];
const manyFlags = Array(5).fill({ type: 'test', impact: 'major_negative' });
const result = gradeParlayFromLegs(legs, manyFlags);
expect(result.confidence).toBe(30);
});
});
+88
View File
@@ -0,0 +1,88 @@
const { generateUpgradePitch } = require('../../src/services/upgradePitch');
function makeMockSupabase(sessions = [], picks = []) {
return {
from: (table) => ({
select: () => ({
eq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: table === 'scan_sessions' ? sessions : picks }),
}),
single: () => Promise.resolve({ data: null }),
}),
}),
}),
};
}
describe('upgradePitch', () => {
test('generates pitch with correct scan count', async () => {
const sessions = [
{ id: '1', final_grade: 'A', legs: ['a', 'b'], created_at: '2026-03-20' },
{ id: '2', final_grade: 'B', legs: ['c', 'd'], created_at: '2026-03-19' },
{ id: '3', final_grade: 'D', legs: ['e'], created_at: '2026-03-18' },
{ id: '4', final_grade: 'C', legs: ['f', 'g'], created_at: '2026-03-17' },
];
const picks = [
{ stat_type: 'points', direction: 'over', grade: 'A', player: 'Jokic' },
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Jokic' },
{ stat_type: 'rebounds', direction: 'over', grade: 'C', player: 'LeBron' },
];
const supabase = makeMockSupabase(sessions, picks);
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'B', legs: [] });
expect(result.hook).toContain('5'); // 4 prior + 1 current
expect(result.insight).toContain('points');
expect(result.cta).toContain('founder rate');
expect(result.tier_recommended).toBeDefined();
expect(result.founder_price).toBeDefined();
});
test('recommends analyst for casual bettors (avg <= 4 legs)', async () => {
const sessions = [
{ id: '1', final_grade: 'B', legs: ['a', 'b'], created_at: '2026-03-20' },
{ id: '2', final_grade: 'B', legs: ['c', 'd'], created_at: '2026-03-19' },
];
const picks = [
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Jokic' },
];
const supabase = makeMockSupabase(sessions, picks);
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'A', legs: [{ stat_type: 'points' }] });
expect(result.tier_recommended).toBe('analyst');
expect(result.founder_price).toBe('$14.99/mo');
});
test('recommends desk for power users (avg > 4 legs)', async () => {
const sessions = [
{ id: '1', final_grade: 'B', legs: ['a', 'b', 'c', 'd', 'e'], created_at: '2026-03-20' },
{ id: '2', final_grade: 'A', legs: ['f', 'g', 'h', 'i', 'j', 'k'], created_at: '2026-03-19' },
];
const picks = [
{ stat_type: 'points', direction: 'over', grade: 'A', player: 'Jokic' },
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'LeBron' },
{ stat_type: 'rebounds', direction: 'over', grade: 'A', player: 'Giannis' },
{ stat_type: 'assists', direction: 'over', grade: 'B', player: 'Trae' },
{ stat_type: 'points', direction: 'over', grade: 'B', player: 'Luka' },
];
const supabase = makeMockSupabase(sessions, picks);
const result = await generateUpgradePitch(supabase, 'user-1', {
grade: 'B',
legs: [{ stat_type: 'points' }, { stat_type: 'rebounds' }, { stat_type: 'assists' }, { stat_type: 'points' }, { stat_type: 'steals' }],
});
expect(result.tier_recommended).toBe('desk');
expect(result.founder_price).toBe('$34.99/mo');
});
test('handles no prior scans gracefully', async () => {
const supabase = makeMockSupabase([], []);
const result = await generateUpgradePitch(supabase, 'user-1', { grade: 'C', legs: [{ stat_type: 'points' }] });
expect(result.hook).toContain('1'); // just the current scan
expect(result.tier_recommended).toBeDefined();
});
});