Files
builtbykev 0996320bd1 S3 (a1): affiliate + partner plumbing
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.

- BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js
  (all books enabled:false, Impact/Partnerize param shapes documented,
  empty params skipped). Wired into StatStrip BookItTeaser (real anchor
  now) + scan hand-off links. Every book anchor renders
  rel="sponsored noopener noreferrer" (BOOK_LINK_REL).
- Best-price marker: slateAdapter.detectBestBook (only when >=2 books
  post the SAME line and prices differ — absent beats wrong) + subtle
  signal-green dot in StatStrip. Slate.groupByGame threads the grouped
  per-book rows (books[]) onto PropRowProp instead of discarding them.
- Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch,
  PartnerRefCapture in layout) -> signup metadata partner_ref ->
  internal GET /api/partners/report/:code (requireInternalAuth; honest
  zeros + note until the TODO migration in docs/PARTNERS.md adds
  user_profiles.partner_ref — NOT run). Stripe promo-code convention:
  partner code == promotion code, verbatim.
- Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens
  invariants updated to the new (stronger) rel contract. Web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:28:18 -04:00

4.6 KiB

VYNDR Partner + Affiliate Plumbing (A1 Session 3)

Everything here ships DISABLED / organic by default. No affiliate program is approved yet; nothing spends money and no link carries tracking params until an operator flips config. Data semantics apply to attribution too — the report endpoint returns zeros before it returns invented numbers.

A partner shares:

https://vyndr.app/?ref=CODE
  • CODE — A-Z 0-9 dash underscore, max 32 chars, case-insensitive (canonicalized to UPPERCASE everywhere).
  • On first visit PartnerRefCapture (mounted in the layout) sets the first-party cookie vyndr_ref for 90 days (SameSite=Lax, Path=/).
  • Attribution is FIRST TOUCH: an existing cookie is never overwritten.
  • On email/password signup, AuthContext.signUp forwards the cookie value into Supabase signup metadata as partner_ref (auth.users.raw_user_meta_data.partner_ref).
  • Known limitation: OAuth signups (Google, etc.) do not carry signup metadata — those signups are not attributed yet.

2. Partner report endpoint (internal)

GET /api/partners/report/:code
Header: x-internal-key: $VYNDR_INTERNAL_KEY

Response:

{ "ok": true, "code": "CODE", "signups": 0, "conversions": 0, "mrr_attributed": 0 }
  • signups — user_profiles rows with partner_ref = CODE
  • conversions — those currently subscription_status = 'active' on a paid tier
  • mrr_attributed — sum of the real monthly price per active profile (analyst 19.99 / 14.99 founder, desk 49.99 / 34.99 founder)

TODO migration (documented, NOT run)

user_profiles has no partner_ref column today, and signup metadata lives in auth.users.raw_user_meta_data, which PostgREST cannot query. Until the migration below is applied, the endpoint returns zeros with a note. Once applied, real numbers flow with no code change.

-- TODO migration: 021_partner_ref.sql (do not run until reviewed)
alter table public.user_profiles
  add column if not exists partner_ref text;

create index if not exists idx_user_profiles_partner_ref
  on public.user_profiles(partner_ref)
  where partner_ref is not null;

-- Copy the signup metadata field on profile creation
-- (extend the existing handle_new_user trigger):
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.user_profiles (id, email, partner_ref)
  values (
    new.id,
    new.email,
    upper(nullif(new.raw_user_meta_data->>'partner_ref', ''))
  )
  on conflict (id) do nothing;
  return new;
end;
$$ language plpgsql security definer;

-- One-time backfill for signups that predate the column:
update public.user_profiles p
set partner_ref = upper(u.raw_user_meta_data->>'partner_ref')
from auth.users u
where u.id = p.id
  and p.partner_ref is null
  and nullif(u.raw_user_meta_data->>'partner_ref', '') is not null;

Note: migration 004 (referral_codes / referral_conversions) is the separate USER-referral system (codes owned by auth users). Partner codes are external strings and deliberately do not depend on those tables.

3. Stripe promo-code mapping convention

One code, three systems, zero lookup tables:

  • The partner's ref code IS their Stripe promotion code, verbatim. Partner "HOOPSPOD" → ?ref=HOOPSPOD link → Stripe promotion code HOOPSPOD on checkout.
  • Codes are created in Stripe as promotion codes on a shared partner coupon; set promotion code metadata partner: <CODE> so Stripe reporting can be cross-checked against /api/partners/report/:code.
  • Cookie-attributed signups and promo-code redemptions are reconciled by the shared code string — a signup that used the promo but arrived without the cookie still attributes through Stripe's side.
  • Founder codes (VYNDR, BETONBLK, FOUNDER_CODE_EXPIRY in stripeService) are NOT partner codes — do not issue a partner code that collides with a founder code.
  • web/src/lib/bookLinks.js builds every sportsbook deep link (StatStrip BOOK IT + scan hand-off). Organic shape: https://{host}/?search={player}.
  • web/src/lib/affiliateConfig.js is the single flip point: per book { enabled: false, params: {} }. Param shapes (Impact: irclickid / sharedid / wpsrc; Partnerize-style: btag / afid / siteid) are documented in that file. Empty param values are skipped — a half-filled config degrades to organic, never a fabricated id.
  • Every book anchor renders rel="sponsored noopener noreferrer" (BOOK_LINK_REL) — required for affiliate compliance, harmless organic.
  • BetRivers accepts an optional two-letter state for its state subdomain (mi.betrivers.com); anything else falls back to www.