diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 200b399..ba0bcee 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) { return matchPlayers(people, query, opts.limit || 12); } -async function searchPlayer(name, season = DEFAULT_SEASON) { +// ── Namesake disambiguation (Wave 1 · trust bug) ───────────────────────────── +// Two different players can share an EXACT nameKey ("James Wood" — the Nationals +// star + a Cubs-affiliate namesake). Taking the first `.find` match silently +// tagged the wrong team → wrong opponents → "built vs AL East" fabrication in +// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a +// confident team hint (the prop's game participants); otherwise refuse (null). + +// The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs. +// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal. +const ABBR_ALIAS = Object.freeze({ + AZ: 'ARI', ARI: 'ARI', + CHW: 'CWS', CWS: 'CWS', + WSN: 'WSH', WSH: 'WSH', + SDP: 'SD', SD: 'SD', + SFG: 'SF', SF: 'SF', + TBR: 'TB', TB: 'TB', + KCR: 'KC', KC: 'KC', +}); +function canonAbbr(a) { + const u = String(a || '').toUpperCase().trim(); + return ABBR_ALIAS[u] || u; +} +function teamNorm(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +/** + * Does a resolved team record (`{ id, name }`, from a candidate's currentTeam) + * match ANY identifier in the prop's team hint? A hint entry may be an abbr + * ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals"). + * teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn + * an abbr hint into a team id. + */ +function teamRecordMatchesHint(team, teamHint, teams) { + if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false; + const tId = team.id; + const tName = teamNorm(team.name); + for (const h of teamHint) { + if (!h) continue; + // 1) hint as a name (equal / either-contains — handles "Nationals" vs full) + const hn = teamNorm(h); + if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true; + // 2) hint as an abbr → resolve to a team id via the teams list, compare ids + const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h)); + if (rec && tId != null && rec.id === tId) return true; + } + return false; +} + +/** Among namesake candidates, return the SINGLE one whose currentTeam matches + * the hint, else null (2+ or 0 matches → refuse; never guess). */ +function disambiguateByHint(candidates, teamHint, teams) { + if (!Array.isArray(teamHint) || teamHint.length === 0) return null; + const matches = (candidates || []).filter((p) => + p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams)); + return matches.length === 1 ? matches[0] : null; +} + +/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial + * hit, else null. A missing profile beats another player's log. */ +function lastNameInitialFallback(people, targetKey) { + const parts = targetKey.split(' '); + const first = parts[0] || ''; + const last = parts[parts.length - 1] || ''; + if (!(first && last && first !== last)) return null; + const cands = (people || []).filter((p) => { + const k = nameKey(p.fullName).split(' '); + return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; + }); + return cands.length === 1 ? cands[0] : null; +} + +/** + * Resolve a name → statsapi person. `opts.teamHint` (array of the prop's game + * team identifiers) disambiguates namesakes AND enforces the join invariant: + * when a hint is present but the resolved player's team is NOT a participant of + * the prop's game, the team is DROPPED (returned null) rather than tagging a + * foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests. + */ +async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { const targetKey = nameKey(name); if (!targetKey) return null; - const url = `${BASE}/sports/1/players?season=${season}`; - const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); - const people = (data && Array.isArray(data.people)) ? data.people : []; - let hit = people.find((p) => nameKey(p.fullName) === targetKey); - if (!hit) { - const parts = targetKey.split(' '); - const first = parts[0] || ''; - const last = parts[parts.length - 1] || ''; - if (first && last && first !== last) { - const cands = people.filter((p) => { - const k = nameKey(p.fullName).split(' '); - return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; - }); - if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess + + let people = opts.people; + if (!Array.isArray(people)) { + const url = `${BASE}/sports/1/players?season=${season}`; + const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); + people = (data && Array.isArray(data.people)) ? data.people : []; + } + + const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null; + let teams = Array.isArray(opts.teams) ? opts.teams : null; + const ensureTeams = async () => { + if (teams) return teams; + try { teams = await getTeams(season); } catch { teams = []; } + return teams; + }; + + const exact = people.filter((p) => nameKey(p.fullName) === targetKey); + let hit = null; + let teamConfirmed = true; // stays true when there's no hint to check against + + if (exact.length === 1) { + hit = exact[0]; + if (teamHint && hit.currentTeam) { + teamConfirmed = teamRecordMatchesHint( + { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); + } + } else if (exact.length >= 2) { + // Namesake collision — resolve ONLY with a confident hint, else refuse. + hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null; + // a hit here is team-confirmed by construction. + } else { + hit = lastNameInitialFallback(people, targetKey); + if (hit && teamHint && hit.currentTeam) { + teamConfirmed = teamRecordMatchesHint( + { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); } } + if (!hit) return null; return { id: hit.id, fullName: hit.fullName ?? name, - team: hit.currentTeam?.name ?? null, - teamId: hit.currentTeam?.id ?? null, + team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null, + teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null, position: hit.primaryPosition?.abbreviation ?? null, }; } @@ -219,9 +320,9 @@ async function searchPlayer(name, season = DEFAULT_SEASON) { * last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns * { found: false } on any miss/failure (never throws). */ -async function getPlayerStats(name, season = DEFAULT_SEASON) { +async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) { try { - const person = await searchPlayer(name, season); + const person = await searchPlayer(name, season, opts); if (!person) return { found: false }; const group = person.position === 'P' ? 'pitching' : 'hitting'; const [seasonStat, log] = await Promise.all([ @@ -293,5 +394,9 @@ module.exports = { getTeams, resolveTeam, getTeamRoster, - __internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON }, + __internals: { + BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, + // Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures). + canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback, + }, }; diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 64a9ba8..5b71a68 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) { try { if (sp === 'mlb') { const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); - const res = await mlb.getPlayerStats(name); + // Wave 1 — thread the prop's game team hint so a namesake collision + // (two "James Wood") resolves to the RIGHT player, and a team that isn't + // a participant of the prop's game is dropped (never a foreign tag). + const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint }); if (!res || !res.found) return { found: false }; const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season); // Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G) diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 4479b43..b65b1c9 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -298,6 +298,18 @@ async function runSnapshot(sport, opts = {}) { // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // and the slate join guard (a prop only attaches to its own game). const teamByPlayer = {}; + // Wave 1 (trust bug) — the prop's game participants become the resolve's + // teamHint: it disambiguates namesake collisions (two "James Wood") and, when + // the resolved player's real team isn't in the prop's game, the resolver drops + // the team rather than tag a foreign one (the streaks/rosterlogs JOIN + // INVARIANT, mirroring the S59 slate guard). Keyed by the normalized name. + const teamHintByPlayer = {}; + for (const p of props || []) { + const k = norm(p.player); + if (!k || teamHintByPlayer[k]) continue; + const hint = [p.home_team, p.away_team].filter(Boolean); + if (hint.length) teamHintByPlayer[k] = hint; + } // Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks + // hot lists) starved because its data producers were all external and // unarmed (tank01-prefetch via n8n, the offline Python grading flow). @@ -308,7 +320,8 @@ async function runSnapshot(sport, opts = {}) { const logEntries = []; await mapLimit(players, STATS_CONCURRENCY, async (player) => { try { - const stats = await deps.resolveStats(player, sp); + const teamHint = teamHintByPlayer[norm(player)] || null; + const stats = await deps.resolveStats(player, sp, teamHint ? { teamHint } : {}); if (stats && stats.found) { const c = deps.classify(sp, stats.classifierInput || {}); archByPlayer[player] = c.primary ? c.primary.name : null; diff --git a/tests/unit/billingDisplay.test.js b/tests/unit/billingDisplay.test.js new file mode 100644 index 0000000..94dbbc9 --- /dev/null +++ b/tests/unit/billingDisplay.test.js @@ -0,0 +1,48 @@ +// Wave 1 trust bug — honest renewal render. classifyRenewal must never let a +// far-future / comped `subscription_end` (the "RENEWS 6/9/2036" lie) render as +// a real monthly renewal, and must fail closed to `unknown` on bad input. + +const { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS } = require('../../web/src/lib/billingDisplay'); + +const NOW = Date.parse('2026-07-13T00:00:00.000Z'); +const DAY = 86_400_000; + +describe('classifyRenewal — honest billing render', () => { + test('MONTHLY_RENEWAL_MAX_DAYS is 60', () => { + expect(MONTHLY_RENEWAL_MAX_DAYS).toBe(60); + }); + + test('the 2036 comped/seed row is NOT a renewal → none', () => { + const r = classifyRenewal('2036-06-09T00:00:00.000Z', NOW); + expect(r.kind).toBe('none'); + expect(r.iso).toBeUndefined(); + }); + + test('a plausible monthly next-bill (now + 30d) → date, carries iso', () => { + const r = classifyRenewal(new Date(NOW + 30 * DAY).toISOString(), NOW); + expect(r.kind).toBe('date'); + expect(typeof r.iso).toBe('string'); + expect(Date.parse(r.iso)).toBe(NOW + 30 * DAY); + }); + + test('absent value (null / undefined / empty) → unknown', () => { + expect(classifyRenewal(null, NOW).kind).toBe('unknown'); + expect(classifyRenewal(undefined, NOW).kind).toBe('unknown'); + expect(classifyRenewal('', NOW).kind).toBe('unknown'); + }); + + test('a renewal well in the past → lapsed', () => { + const r = classifyRenewal(new Date(NOW - 10 * DAY).toISOString(), NOW); + expect(r.kind).toBe('lapsed'); + }); + + test('malformed date string → unknown (never coerced to an epoch date)', () => { + expect(classifyRenewal('not a date', NOW).kind).toBe('unknown'); + expect(classifyRenewal('N/A', NOW).kind).toBe('unknown'); + }); + + test('exactly at the 60-day boundary still reads as a date; just beyond → none', () => { + expect(classifyRenewal(new Date(NOW + 60 * DAY).toISOString(), NOW).kind).toBe('date'); + expect(classifyRenewal(new Date(NOW + 61 * DAY).toISOString(), NOW).kind).toBe('none'); + }); +}); diff --git a/tests/unit/ds5PricingStates.test.js b/tests/unit/ds5PricingStates.test.js index 51ace72..6c2f934 100644 --- a/tests/unit/ds5PricingStates.test.js +++ b/tests/unit/ds5PricingStates.test.js @@ -47,10 +47,14 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () => expect(pricing).not.toContain("originalPrice: '$49.99'"); }); - test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => { + test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => { expect(page).toContain('import DeskShowcase'); expect(page).toContain(' { + test('(a) a single unambiguous name resolves (Aaron Judge → Yankees)', async () => { + const r = await mlb.searchPlayer('Aaron Judge', 2026, { people: [JUDGE], teams: TEAMS }); + expect(r).not.toBeNull(); + expect(r.id).toBe(592450); + expect(r.team).toBe('New York Yankees'); + }); + + test('(b) two "James Wood" with NO hint → null (NEVER the Cubs one)', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS }); + expect(r).toBeNull(); // refuse — honest absent beats wrong + }); + + test('(c) two "James Wood" + a Nationals-game hint → the Nationals Wood', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { + people: [WOOD_CUBS, WOOD_NATIONALS], // Cubs first — the old bug picked this + teams: TEAMS, + teamHint: ['WSH', 'MIA'], // Nationals @ Marlins + }); + expect(r).not.toBeNull(); + expect(r.id).toBe(691026); + expect(r.team).toBe('Washington Nationals'); + expect(r.team).not.toBe('Chicago Cubs'); + }); + + test('(c2) a full-team-name hint disambiguates too', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { + people: [WOOD_CUBS, WOOD_NATIONALS], + teams: TEAMS, + teamHint: ['Washington Nationals', 'Miami Marlins'], + }); + expect(r.id).toBe(691026); + }); + + test('(c3) two namesakes + a hint matching NEITHER → null (still refuse)', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { + people: [WOOD_NATIONALS, WOOD_CUBS], + teams: TEAMS, + teamHint: ['LAD', 'SD'], + }); + expect(r).toBeNull(); + }); +}); + +// JOIN INVARIANT (streaks/rosterlogs) — a resolved player's team must be a +// participant of the prop's game; on mismatch the team is DROPPED, never a +// foreign tag. This is the mechanism snapshotService relies on before it writes +// teamByPlayer + rosterlogs opponents. +describe('searchPlayer — team join invariant (drop, never tag foreign)', () => { + test('a single player whose team is NOT in the hinted game → team dropped to null', async () => { + const r = await mlb.searchPlayer('Juan Soto', 2026, { + people: [SOTO_METS], + teams: TEAMS, + teamHint: ['LAD', 'SD'], // Soto (Mets) is in neither → cannot confirm join + }); + expect(r).not.toBeNull(); // still the right player by name + expect(r.id).toBe(665742); + expect(r.team).toBeNull(); // but his team is not tagged onto a foreign game + expect(r.teamId).toBeNull(); + }); + + test('a single player whose team IS in the hinted game keeps its team', async () => { + const r = await mlb.searchPlayer('Juan Soto', 2026, { + people: [SOTO_METS], + teams: TEAMS, + teamHint: ['NYM', 'LAD'], // Mets @ Dodgers → confirmed + }); + expect(r.team).toBe('New York Mets'); + }); + + test('with NO hint, a single player keeps its authoritative team', async () => { + const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS }); + expect(r.team).toBe('New York Mets'); + }); +}); + +describe('pure helpers — abbr reconciliation + hint matching', () => { + test('ESPN↔statsapi abbr aliases canonicalize (AZ↔ARI, CHW↔CWS)', () => { + expect(canonAbbr('AZ')).toBe(canonAbbr('ARI')); + expect(canonAbbr('CHW')).toBe(canonAbbr('CWS')); + expect(canonAbbr('nyy')).toBe('NYY'); + }); + + test('teamRecordMatchesHint matches by abbr via the teams list', () => { + expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['WSH'], TEAMS)).toBe(true); + expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['CHC'], TEAMS)).toBe(false); + }); + + test('teamRecordMatchesHint matches by partial name', () => { + expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['Nationals'], TEAMS)).toBe(true); + }); + + test('disambiguateByHint refuses when 2 candidates share the hinted team', () => { + const dupe = [WOOD_NATIONALS, { ...WOOD_CUBS, currentTeam: { id: 120, name: 'Washington Nationals' } }]; + expect(disambiguateByHint(dupe, ['WSH'], TEAMS)).toBeNull(); + }); +}); diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx index 8f8cb0b..9e9e236 100644 --- a/web/src/app/pricing/DeskShowcase.tsx +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -36,7 +36,7 @@ export default function DeskShowcase() { THE DESK · FLAGSHIP

- A $1M terminal.{' '} + Every grade, every alt line, live.{' '} $44.99 /mo.

diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 2ee04f5..a6bae28 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -36,7 +36,7 @@ export const metadata: Metadata = { export default function PricingPage() { return (
- {/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the + {/* DS5 (#8) — Desk is the hero. The value-showing story leads, above the grid, so the premium tier stops being an afterthought. */} diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index 2ec8da8..fbe3505 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { currentAccessToken } from '@/lib/authToken'; +import { classifyRenewal } from '@/lib/billingDisplay'; interface FullProfile { id: string; @@ -125,7 +126,7 @@ export default function ProfilePage() { {tier !== 'free' && (
- +
)} @@ -170,7 +171,7 @@ export default function ProfilePage() {

Cancellation scheduled. Access ends{' '} - {profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}. + {accessEndsLabel(profile.subscription_end)}.

)} @@ -199,6 +200,25 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'go ); } +// Renewal render is guarded (Wave 1 trust bug) — VYNDR tiers are monthly, so a +// `subscription_end` far in the future is a comped/seed value, not a renewal. +// classifyRenewal decides; we never print a raw date the cadence can't justify. +function renewalLabel(subscriptionEnd: string | null): string { + const r = classifyRenewal(subscriptionEnd); + if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString(); + if (r.kind === 'none') return 'No scheduled renewal'; + if (r.kind === 'lapsed') return 'Lapsed'; + return '—'; +} + +// The cancel-scheduled line only shows a real near date; anything else falls to +// the honest "at period end" (never a fabricated 2036 access-end). +function accessEndsLabel(subscriptionEnd: string | null): string { + const r = classifyRenewal(subscriptionEnd); + if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString(); + return 'at period end'; +} + function tierColor(tier: string): string { if (tier === 'desk') return 'var(--grade-a)'; if (tier === 'analyst') return 'var(--grade-b)'; diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx index 6287e00..ec9e84a 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -89,7 +89,7 @@ const TIERS: TierConfig[] = [ highlight: false, }, { - // DS5 (Part 6, #8) — Desk is THE hero tier. It carries the "$1M terminal" + // DS5 (Part 6, #8) — Desk is THE hero tier. It carries the value-showing // story and the single primary CTA on the grid (color contract #9: never // two competing green CTAs). $44.99 regular, $34.99 for founders. id: 'desk', diff --git a/web/src/lib/billingDisplay.js b/web/src/lib/billingDisplay.js new file mode 100644 index 0000000..c0f8452 --- /dev/null +++ b/web/src/lib/billingDisplay.js @@ -0,0 +1,58 @@ +/* ============================================================ + VYNDR — BILLING DISPLAY (honest renewal render). + Plain CommonJS so .tsx components import it AND the Jest suite + requires it directly (same pattern as colorContract.js / checkout.js). + + The lie this kills: the profile page rendered `subscription_end` + verbatim, so a manually-seeded / comped founder row reading + "6/9/2036" showed as a real renewal. VYNDR tiers are MONTHLY only + (no annual), so any date more than ~60 days out is NOT a plausible + monthly next-bill — it is a comped / lifetime / seed value and must + NOT be rendered as a renewal date. + + Doctrine: absent-but-honest beats wrong-but-full. Never render a + renewal date the billing cadence can't justify. Strict parsing + (the `Number(null) === 0` class of bug) — an unparseable value is + `unknown`, never coerced to an epoch date. + ============================================================ */ + +// A monthly plan renews ~30 days out; allow slack for proration / grace, +// but a value beyond this many days out cannot be a monthly renewal. +const MONTHLY_RENEWAL_MAX_DAYS = 60; + +// A renewal more than this many days in the PAST is a lapsed subscription +// (a small grace window absorbs clock skew / just-past renewals). +const LAPSED_GRACE_DAYS = 2; + +const DAY_MS = 86_400_000; + +/** + * classifyRenewal(subscriptionEnd, nowMs) → { kind, iso? } + * + * kind ∈ + * 'date' — a plausible monthly renewal (0..~60d out). Carries `iso` + * (the parsed timestamp) for the caller to localize. + * 'none' — more than ~60d out → comped / lifetime / seed value; there + * is no scheduled monthly renewal to show. + * 'lapsed' — more than 2d past → the subscription window has ended. + * 'unknown' — absent / empty / unparseable → show an em dash. + * + * @param {string|number|null|undefined} subscriptionEnd provider-asserted end. + * @param {number} [nowMs] current epoch ms (injectable for tests). + */ +function classifyRenewal(subscriptionEnd, nowMs = Date.now()) { + if (subscriptionEnd === null || subscriptionEnd === undefined || subscriptionEnd === '') { + return { kind: 'unknown' }; + } + const t = Date.parse(subscriptionEnd); + if (Number.isNaN(t)) return { kind: 'unknown' }; + + const now = Number.isFinite(nowMs) ? nowMs : Date.now(); + const diffDays = (t - now) / DAY_MS; + + if (diffDays < -LAPSED_GRACE_DAYS) return { kind: 'lapsed' }; + if (diffDays > MONTHLY_RENEWAL_MAX_DAYS) return { kind: 'none' }; + return { kind: 'date', iso: new Date(t).toISOString() }; +} + +module.exports = { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS };