NexaPay was cross-project contamination (from another venture) — never a real VYNDR payment path. Purged; Stripe path untouched. Removed: - web/src/services/nexapay.ts (createPaymentLink/getTransaction/HMAC verify) - web/src/app/api/webhook/nexapay/route.ts (the only importer; Next-registered, reachable — now gone) - NexaPay comments in email.ts + checkout/route.ts - Active NexaPay entries in docs/SYSTEM-MANIFEST.md (route list, NEXAPAY_* env table, service row) + stale claim in wiring-data-train.md - sw.js precache entry for the deleted webhook chunk Verified: ZERO NexaPay in code (web/src, src, tests). Full suite 3833 green (count unchanged — nothing depended on it, confirming it was dead). Web build exit 0. sw.js parses clean. Stripe checkout untouched (Next→Express→Stripe). FLAGGED FOR KEV (a repo delete cannot close these): - Coolify env: remove NEXAPAY_API_KEY / NEXAPAY_WEBHOOK_SECRET / NEXAPAY_API_URL - Revoke the NexaPay API key + webhook secret at NexaPay's dashboard; de-register the webhook if an account was ever configured - DB column user_profiles.nexapay_customer_id is orphaned (no reader/writer) — drop via a follow-up migration (migration 011 left as history) Cross-project check: ZERO Noctem-Supabase refs; VYNDR references only its own Supabase (zmdnczhtdxcddsxzttub). NexaPay was the sole contamination found. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
19 KiB
VYNDR — WIRING & DATA TRAIN · STEP 0 MAP
Branch wiring/data-train. The map before the build. Governing law: the $1M/mo feeling is PRODUCED, never claimed. Honesty guardrail: real asset where genuinely sourceable; honest fallback (monogram/text/absent) where not. Never fabricate or scrape a real person's face/data to fill a hole.
Legend: ✅ wire-able now (data already in the system) · 🔧 sourceable free (real source exists, needs a new adapter, zero-out-of-pocket) · ⛔ blocked (no honest source today → keep fallback) · 🆕 net-new.
STEP 1 — TWO TRUST BUGS
1a. Billing "RENEWS 6/9/2036" — ✅ fixable
- Render:
web/src/app/profile/page.tsx:128renderssubscription_endverbatim from the Supabaseuser_profilesrow. Honest render, bad data — no hardcoded 2036 in source. - Root cause (UPDATED 2026-07-27 — NexaPay purged): VYNDR is Stripe-only. The old note pointed at a
web/src/app/api/webhook/nexapay/route.tsthat wrotesubscription_end = now + 30 days— that webhook was cross-project contamination and has been DELETED. The 2036 value is a manually-seeded/comped founder-account row the UI trusts blindly. The real Stripecurrent_period_endexists atGET /api/stripe/status(stripeService.js:278) but the UI never consumes it. - Task: point the "Renews" stat at the Stripe provider-asserted value (consume
/api/stripe/status.current_period_end); correct/clear the stale 2036 row. Guard: never render asubscription_endthe provider didn't assert → fall back to—.
1b. James Wood → "Chicago Cubs" / builds vs AL East — ✅ fixable (nameKey collision)
- Root cause:
mlbStatsAdapter.js:192people.find(p => nameKey(p.fullName) === targetKey)returns the first exact-nameKey match with no namesake disambiguation. A second "James Wood" in the statsapi season list (a Cubs-affiliate namesake) wins → wrongcurrentTeam. The S59 substring guard only covered fuzzy mismatches; it never covered two players sharing an exact name — not a regression, an uncovered case. - Propagation: wrong team enters
snapshotService.js:315teamByPlayer+ rosterlogs opponents →streaksService.js:229team+ opponents →streakLens.js:79-82"built vs" / tonight's-opponent. The S59 slate JOIN INVARIANT guards only Slate cards; the streaks/rosterlogs path bypasses it. - Task: add an optional
teamHinttosearchPlayer/getPlayerStats/resolveStats; on multiple exact-nameKey matches pick the one whosecurrentTeammatches the hint, else return null (never guess — S59 doctrine). InsnapshotService.js:311pass the prop's game team as the hint and dropstats.team/rawLog if it disagrees with the prop's game participants. - Test: join-invariant — a resolved player's
teammust equal / be a participant of the prop's game; a rosterlogs entry'steammust match the graded prop's team → omit on mismatch, never tag a foreign team.
STEP 2 — ENTITY LAYER, FULLY FED
Root-cause verdict: team logos already render live. Player headshots break at a data-threading gap, not a helper bug — the MLBAM id is fetched then severed before it reaches the browser.
| Surface | Asset | Status | Task |
|---|---|---|---|
| Game-card team logos | ESPN logo CDN | ✅ live (vyndr/GameCard.tsx:121 TeamLogo) |
none; add any missing abbrs to teamMeta.js |
| MLB slate headshots | img.mlbstatic.com/…/people/{id}/headshot |
✅ id fetched (playerIntelService.js:141) but severed at snapshotService.js:331-336 |
thread playerId → enriched grade → slateAdapter.buildPlayerStripsFromProps → StatStrip.tsx:381 playerId={ps.playerId} |
| MLB scan-search headshots | same | ✅ id already on /api/players/search p.id |
pass playerId:p.id at scan/page.tsx:566 (+ SearchModal.tsx) |
| MLB hot-list headshots | same | ✅ id via rosterlogs |
HotListPanel.tsx:68 uses raw silhouette <img> → swap for PlayerAvatar (branded monogram on null) |
| NBA/WNBA headshots | cdn.nba.com / ESPN athlete id | ⛔ no real id in data (search returns synthetic sport-i-key; resolvePlayerStats NBA branch emits none) |
source a real id first, or keep honest monogram. NBA/WNBA don't settle yet → low ROI now |
| Soccer headshots | — | ⛔ no central CDN (by design) | honest monogram |
| Ledger book wordmarks | BookWordmark (brand-color text) |
✅ built but never imported; renders raw lowercase row.book at ledger/page.tsx:368 |
import BookWordmark; add 6 missing keys to books.js (fanatics/bet365/hardrockbet/betrivers/pointsbet/pinnacle fall to gray) |
- Sportsbook wordmarks: the work order asks for local SVGs. Current
BookWordmarkis brand-color text. Decision below (D3) — text-wordmark ships today; SVGs are an upgrade. - Headshot normalization:
PlayerAvataralready enforces size/round treatment; extend to guarantee consistent crop when a real photo loads.onError→monogram is first-class (no broken-image icon ever).
Wire-able now: MLB headshots (3 surfaces) + ledger wordmarks. Blocked: NBA/WNBA headshots (no id source).
STEP 3 — OUTLOOK MODE (never-empty slate) — ✅ wire-able now
- DS2's
heroFallbackState/buildHeroReceipts(slateAdapter.js:467-495) covers ONLY the "Top grades tonight" hero (dashboard/page.tsx:314-418) — never blank there. Gap: the<Slate>game grid (Slate.tsx:1001-1013emptyStateCopy) AND the dashboard's separate "Today's games" section (page.tsx:421-439) each independently fall to a dead-end "NO SLATE" card. - The
"No games available…"string atSlate.tsx:609is the networkfetchErrorstate, distinct from empty-slate. - Fill data already fetched: yesterday's settled receipts (
/api/ledger/model, already loaded), tomorrow's date-pinned schedule (scheduleService), ticker items, month-awareemptyStateCopy. Only the wiring into the grid branch is new. - Task: extend the DS2 fallback below the hero — when
games.length===0, render receipts / tomorrow's schedule preview inside the grid slot in bothSlate.tsx:1001anddashboard/page.tsx:424, not a CTA card.
STEP 4 — MISSING SURFACES
| Surface | Status | Verdict |
|---|---|---|
| Live grade-shift timeline | ✅ wire-able now | backend done (intradayRefreshService revisions + revised_from_grade + line history); reaches UI as per-row chip (StatStrip.tsx:171) + LineSparkline. Missing only a timeline VIEW assembled from already-emitted movement/history data. No new backend. |
| Market-breadth / consensus-vs-model | ✅ wire-able now | per-prop books[] (Slate.tsx:373) + model_value + snapshot line-deltas already present. Missing only a component that computes median-book-vs-model. DeskShowcase.tsx:89 currently advertises it as copy — must become real or the claim goes. |
| Parlay Lab builder | ✅ math done, 🆕 UI | parlayService//api/parlay/grade/ParlayContext all live; combined grade + correlation + payout render in ParlayPanel. "Empty tray" because the only leg source is the "+" on live slate rows and there's no /parlay page. Task: build a Parlay Lab page with a prop search/browse leg source independent of the slate. |
| Pitcher arsenal (mix/velo/usage/whiff%) | 🔧 sourceable free / 🆕 feed | NOT in statsapi — this is Statcast / Baseball Savant (free, public). Needs a genuinely new Savant adapter, not wiring. |
| /u public profile | ✅ route live, ⛔ no record | route + aggregate machinery work; /u/vyndr 200s to an honest not-found because no public_profiles row is claimed+published AND getModelAggregate({userId}) needs ≥20 settled rows under that user. Structural note: it scopes to a user's own ledger — the house model record is user_id=NULL and isn't surfaceable as a user profile without a "house profile" option. |
Wire-able now: grade-shift timeline, consensus strip, Parlay Lab UI. New free feed: pitcher arsenal (Savant). Data-gated: /u real record (needs a published house profile — see D2).
STEP 5 — COMBAT (net-new, largest lift, sequenced last)
- Verdict: genuinely net-new. Only inert placeholders exist (
sports.jsmma:{active:false,comingSoon}, a share-card color, a dormant ESPN URL inESPNAdapter.js, Pinnacle id 22). No pipeline/route/service/archetype/grade path. No combat spec exists in the repo — it must be authored as Step 5's first artifact. - PropLine has no combat → combat cannot ride the abundant-props path; it's odds-api-only, which changes the quota story.
- Free source option space (report-only, no pick):
- ESPN MMA (
site.api.espn.com/…/mma/ufc/scoreboard|summary) — free JSON, no auth, same family VYNDR already uses; fight cards/results/method/round + some bio. Thinner stat depth than ufcstats. The URL already sits dormant inESPNAdapter.js. - ufcstats.com — richest tale-of-the-tape + striking/grappling granularity, but HTML scraping → needs a parser dep (cheerio) + fragile; UFC-only.
- The Odds API
mma_mixed_martial_arts— already paid (ODDS_API_KEY); realistically moneyline + round totals only. Method-of-victory/round/props are thin-to-absent on the standard feed → data-constrained, not just build-constrained. - Wikipedia/Wikidata — bio backstop only.
- ESPN MMA (
- Honesty caveats: never ingest fighter photos (likeness/rights — same rule as headshots); scraping must degrade to absent, never fabricated 0; if only ML+round-total are free, the UI must not imply a full method/prop board.
- Biggest architectural departure: combat's unit is a matchup, not a per-player prop → the current
analyzeViaEngine1per-prop feature-vector doesn't fit; a style-matchup grade engine is closer to a new engine than a config add. Settlement needs a new ESPN-MMA result path (like the pending WNBA box-score work). - Cheapest honest v1: ESPN-MMA (free) + odds-api ML/round-totals (paid, existing) + a net-new style-blend archetype set + a matchup-grade path; method/round/props flagged data-limited.
FOUNDER DECISIONS (LOCKED — 2026-07-13)
- Global visual reference:
specs/design-reference/vyndr-system.html(the Claude Design "Vyndr System" mockup, +support.js). Build EVERY surface toward it — layouts, the missing surfaces, color treatments, component designs. Mockup surfaces confirmed present: edge board, hero grade reveal, VYNDR intelligence panel, player identity block, archetype glyphs (74 marks · 8 sports — an expansion target), pitcher identity (arsenal/whiff/velo), correlation builder + parlay slip, live grade-shift (grade-by-game-clock, history·last-24), public record /u (CLV-verified, portrait 1080×1350 + OG 1200×630), fighter tale-of-the-tape (FIGHTER A/VERDICT/FIGHTER B, moneyline/KO/decision, style archetypes), pricing tier, empty/error 404-bar. ONE exception: keep the CURRENT live-site VYNDR wordmark/name, NOT the mockup's. Live wordmark wins; everything else references the mockup. - D1 — OVERRIDE → sport-agnostic entity layer NOW. Don't defer NBA/WNBA to monograms. Capture a stable athlete id per player at ingestion for every league (ESPN cross-sport athlete id is the likely single key covering NBA/WNBA/NFL/NHL/soccer; MLB keeps its MLBAM thread-through). ONE headshot resolver keyed by
(league, id). Monograms remain the honest fallback ONLY where an id genuinely can't resolve. If a league needs a new ingestion step for the id, build it. Report any league with NO free id source. - D2 — default: /u house-mode reading the
user_id=NULLpublic aggregate. - D3 — OVERRIDE → bundle the ~8 real book SVGs in the SAME wave (Wave 2). Text wordmarks are only the stopgap; the SVGs are the real fix and are cheap.
- D4 — default: Baseball Savant adapter for pitcher arsenal (free).
- D5 — default: author the combat spec + ship the honest free v1 (ESPN-MMA + odds-api ML/round-totals; method/round/props flagged data-limited); defer the full matchup-grade engine to its own sub-wave; confirm before any scraping dep.
ADDITION 1 — ASSET STORAGE (ingestion-time resolve)
Resolve id→URL once at ingestion; store the id + resolved-or-404 status in our data so we don't re-check per render. Browser caches the actual image via CDN cache headers (no per-load re-fetch, no per-render re-resolve). Self-hosting/proxying images is a LATER optimization — not now.
ADDITION 2 — RECORD BY GRADE TIER (non-negotiable, core not cosmetic)
The record must be tracked + displayed PER GRADE TIER (A+ went X-Y, A X-Y, B X-Y, C X-Y — each its own hit-rate), everywhere the record shows (dashboard, /u, ledger). A single blended "67%" hides the proof: higher grade wins more often — the tier calibration IS the credibility. Sport/bet-type slices are a plus; per-tier is required. Extend ledgerService.getModelAggregate (and any schema/query it needs) to return per-tier buckets; render them on every record surface. A+ stands alone; then A/B/C/D/F by first letter (mirror the existing outcomeService bucketing).
ADDITION 3 — DESIGN FILE IS THE GLOBAL REFERENCE
Weave in essentially everything from the mockup across all surfaces (see reference note above). Wordmark is the sole exception.
ORIGINAL DECISION DEFAULTS (superseded where noted above)
- D1 — NBA/WNBA headshots: no real id source in the data today, and those sports don't settle yet. Default: ship MLB headshots now; keep honest branded monograms for NBA/WNBA until an id source is added. (Alt: invest in sourcing cdn.nba.com/ESPN ids now.)
- D2 — /u real record: the CLV-verified profile can't show a house record as a user profile (house =
user_id=NULL). Default: add a "house/model" profile mode that reads the publicuser_id=NULLaggregate (the partner-pitch weapon shows the real 55-19), keeping user profiles gated at n≥20. (Alt: claim+publish a house account and let it accrue 20 settled reads — slower, but no code path added.) - D3 — Book wordmarks: Default: ship the existing brand-color text
BookWordmarknow (kills lowercase "betmgm"), bundle local SVGs as a follow-up wave (SVGs are the work order's ask but a bigger drop-in). (Alt: source + bundle the ~8 SVGs before shipping Step 2.) - D4 — Pitcher arsenal: Baseball Savant is free → Default: build the Savant adapter (zero-out-of-pocket). Flag any field Savant doesn't cover as absent.
- D5 — Combat scope: Default: author the combat spec + ship the free honest v1 (ESPN-MMA + odds-api ML/round-totals), flagging method/round/props as data-limited; defer the full matchup-grade engine to its own sub-wave. Confirm before adding any scraping dep (ufcstats/cheerio).
WAVE 2 BUILD PLAN (LOCKED — de-risk findings)
The (league,id)→URL resolver (web/src/lib/playerHeadshot.ts getHeadshotUrl) + PlayerAvatar (headshot-or-monogram via <img onError>) already exist and are correct. PropLine props carry NO id → the id can ONLY come from the stats resolve the snapshot already runs per player (snapshotService.js:311) = Addition 1's intent exactly. Store playerId+espnId on the enriched grade objects at snapshotService.js:331-336 (beside archetype/team) → flows free to grades:{sport} → buildPlayerStripsFromProps → StatStrip playerId. Zero new Redis key, zero new I/O.
| League | Verdict | Wiring |
|---|---|---|
| MLB | ✅ now | MLBAM res.id already captured (into rosterlogs only) — add to enriched; thread to strip/scan/hotlist |
| NBA/WNBA | 🔧 free now | espnStatsAdapter.getSeasonAverages resolves the ESPN athlete id at :89 and discards it — return it as espnId; surface via resolvePlayerStats nba/wnba branch; thread espnId→getHeadshotUrl; runtime-verify ESPN coverage |
| NFL/NHL | dormant | not in ACTIVE_SPORTS (+ off-season); add nfl/nhl to ESPN_SPORT_PATH (playerHeadshot.ts:39); inherits ESPN id when ingested |
| Soccer | ⛔ blocked | no live resolve branch; API-Football has player.id + media.api-sports.io CDN but is key-gated (API_FOOTBALL_KEY) + unwired → honest monogram until a soccer resolve branch + key land. This is the one league to report as no-free-id-today. |
404-status (Addition 1): id resolve is free; a true resolved-or-404 status needs an image HEAD probe (extra I/O). <img onError> already degrades per-render + the browser CDN-caches → ship id-only first; add a bounded HEAD probe in the mapLimit loop only if the one-frame monogram→photo flash is objectionable. Book SVGs (D3) + ledger BookWordmark import ride this wave too.
WAVE SEQUENCE (ship on green, per step; toward the mockup)
- Wave 1 — Step 1 trust bugs (billing renewal date + Wood nameKey-collision + streaks join-invariant test) + the quick DeskShowcase "A $1M terminal" → on-voice deadpan copy fix. Quick, independent, no mockup dep.
- Wave 2 — Step 2 entity layer, SPORT-AGNOSTIC (the eyeball gate). (a) ingestion-time athlete-id capture per league (ESPN cross-sport id + MLB MLBAM) with resolved-or-404 status stored (Addition 1); (b) ONE
(league,id)→headshot resolver; (c) thread id → grade → strip → every surface (slate strips, scan search, hot list, grade cards); (d) ledger book wordmarks — importBookWordmark+ add missing keys + bundle the ~8 real book SVGs; (e) team-logo coverage gaps. STOP + REPORT for founder eyeball of real logos/headshots across ALL sports before the rest builds on it. Report any league with no free id source. - Wave 3 — Record-by-grade-tier (Addition 2, core) — extend
getModelAggregateto per-tier buckets + render on dashboard, ledger, /u. Sets up /u + the credibility story. - Wave 4 — Step 3 outlook mode (never-empty grid → receipts/tomorrow) + Step 4 wire-able-now surfaces toward the mockup: live grade-shift timeline (grade-by-game-clock, history·last-24), market-breadth/consensus strip, Correlation Builder / Parlay Lab page (parlay slip + correlation flag).
- Wave 5 — /u house-mode profile (D2, reads
user_id=NULLaggregate; portrait + OG crops from the mockup) + pitcher identity card fed by the Baseball Savant arsenal adapter (D4, new free feed). - Wave 6 — Step 5 combat (author the combat spec FIRST → free honest v1: ESPN-MMA + odds-api ML/round-totals + style-blend archetype set + tale-of-the-tape card from the mockup; method/round/props flagged data-limited; matchup-grade engine its own sub-wave; confirm before any scraping dep).
Global rule for every wave: build the surface toward specs/design-reference/vyndr-system.html; keep the live wordmark. Ship each wave on green (tests + next build exit 0); nothing to main without the founder's word.