Files
civitai__civitai/prisma
Justin Maier aa1922b4ba feat(referral): spend-triggered referral program v2 (#2178)
* feat(referral): add spend-triggered referral program v2

Referrers earn Tokens (1/2/3 per Bronze/Silver/Gold membership month paid,
capped at 3 months per referee) + 10% Blue Buzz kickback on referee yellow
Buzz purchases. Referees get 25% of tier monthlyBuzz as Blue Buzz on first
paid membership. 7-day settlement window with chargeback revoke. Tokens
redeemable in shop for perk-only CustomerSubscription grants (no buzz stipend,
no tier badge). Gated by Flipt flag referral-program-v2.

- Prisma: ReferralReward, ReferralMilestone, ReferralRedemption models
- UserReferral.firstPaidAt + paidMonthCount for cap tracking
- referral.service.ts handles earning, settlement, clawback, milestones
- Stripe webhook hooks manageInvoicePaid + completeStripeBuzzTransaction
- charge.refunded + charge.dispute.created webhooks for clawback
- Cookie TTL 5d -> 30d, single auto code per user
- referral:* signals (pending, settled, milestone, tier-granted, etc.)
- /user/referrals dashboard with shop modal + activity feed
- Checkout banner with manual code entry + bonus preview
- Terms at src/static-content/referrals/terms.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(referral): address review feedback from code + gap analysis

- Add @@unique([kind, sourceEventId]) + @@index([status, settledAt]) on
  ReferralReward. Replaces fetch-then-insert dedupe with unique-violation
  catch; closes idempotency race on webhook retries.
- Make sourceEventId NOT NULL (always set in practice)
- Fix settleRewardRow: move buzz grant before status flip and revert claim on
  buzz transaction failure. Prior version left Settled status after a
  post-commit rollback attempt.
- revokeForChargeback now handles Settled rewards via negative
  (ChargeBack) createBuzzTransaction, not just Pending.
- Chargeback webhook looks up the linked invoice for the PI so membership
  rewards (sourceEventId = invoice.id) are also revoked.
- Enforce minReferrerAccountAgeDays in resolveReferrerForReferee.
- getReferrerBalance: single groupBy query replaces 4 aggregates.
- redeemTokens: FOR UPDATE lock on settled token rows; grant actual
  CustomerSubscription via findReferralProductForTier. Requires admin
  setup of Civitai-provider products flagged referralGrantable:true.
- awardMilestones: deterministic sourceEventId (userId:threshold) and P2002
  catch prevent double-award under concurrent settlement.
- Referee bonus consolidated into the membership payment transaction.
- ReferralCheckoutBanner now self-fetches per-tier monthly buzz via
  trpc.referral.getTierBonuses and displays the computed bonus.
- trackCheckoutView moved to publicProcedure (anonymous guests can hit it).
- logFraudEvent moved after dedupe checks so only real events log.
- Constants: REWARD_DESCRIPTIONS table, promoted REFERRAL_SYSTEM_ACCOUNT_ID.

Known gaps deferred to follow-ups: Paddle buzz kickback hook,
referral:click emitter + dashboard stat, share buttons (Twitter/Reddit/
Discord), expiring-soon UI card, redemption-vs-paid-tier UI check, Top
Affiliate cosmetic on 1M milestone. See review compile for full list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): address Justin's second-pass feedback

Key changes following review:

- Schema source of truth: moved all referral additions from the generated
  schema.prisma to schema.full.prisma so db:generate regenerates correctly.
- Referral grants use CustomerSubscription.buzzType='referral' (new distinct
  value) so they stack with paid yellow/green/blue subs via session-user's
  highest-tier-across-all aggregation. @@unique([userId, buzzType]) no longer
  blocks a user from holding paid + referral at the same time. On re-redeem,
  existing referral sub is extended (same/lower tier) or upgraded
  (higher tier).
- Attribution logging moved from Axiom to Postgres. New ReferralAttribution
  table links each attribution event to UserReferralCode + payment metadata
  (Stripe PI id, invoice id, charge id, card fingerprint, IP). Indexes
  support "all events for code/card/IP" queries for mod review.
- Stripe invoice + PI webhooks forward payment identifiers + card
  fingerprint into the attribution record.
- Paddle webhook gets a clarifying deprecation comment; no new referral
  paths touch Paddle.
- Notifications (persistent + email) added alongside signals for:
  - referral-reward-settled
  - referral-milestone-hit
  - referral-token-expiring (daily cron, deduped per-user-per-expiry-date)
  - referral-welcome-bonus (referee gets in-app + email thank-you)
- /user/referrals dashboard gains Share-on-X, Share-on-Reddit, and
  Share-on-Discord buttons in the hero.
- 1M-milestone Top Affiliate badge grants the most-recent cosmetic as a
  placeholder until a bespoke one is authored.
- Design doc (referral-program.md) extended with overlap stacking approach,
  attribution + fraud detection plan, and notification matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): tier queue redemptions, no exploit path

Mirrors the prepaid-membership pattern: each redemption becomes a tier-time
chunk stored on CustomerSubscription.metadata.referralQueue. Chunks sort by
tier DESC so Gold always activates before Bronze. Same-tier chunks collapse
into a single entry so the metadata stays small.

On redemption: pool the currently-active chunk's remaining days + existing
queue + new chunk, sort/collapse, promote the top chunk to active, persist
the rest as queue. Cheap-Bronze-stacked-to-Gold exploit is impossible because
chunks never change tier — a Bronze chunk grants Bronze for its own days,
then the next chunk runs on its own terms.

New hourly cron advance-referral-subs: for referral subs past
currentPeriodEnd with a non-empty queue, promote the next chunk; otherwise
cancel the sub.

Static modal copy updated to reflect the new behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(referral): unit tests for queue, earning, chargeback, milestones

24 vitest cases covering:

- collapseTierQueue (pure): sort order, same-tier collapse, zero-duration
  drops, and the "cheap Bronze stacked with one Gold" exploit is impossible
- recordMembershipPaymentReward: no-bound-referrer skip, 3-month cap,
  first-payment referee bonus creation, subsequent-month no-bonus path,
  P2002 idempotency, min-referrer-age rejection
- recordBuzzPurchaseKickback: skip when referee never paid, 10% rate
  calculation
- revokeForChargeback: pending (no buzz clawback) vs settled (negative buzz
  txn with referral-clawback:<id> externalTransactionId)
- awardMilestones: no-op on zero lifetime, awards only qualifying
  thresholds, swallows P2002 on duplicate milestone
- advanceReferralSubscriptions: empty-queue cancels, non-empty promotes
  highest tier, missing referralGrantable product skips (no partial
  progress)

Collapse exports collapseTierQueue for direct testing. DB clients mocked
with deep shape for each model used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): timeline progress bar on dashboard

Adds a segmented progress bar to /user/referrals showing the user's queued
tier-time chunks. Mirrors the prepaid membership timeline pattern: each
chunk is colored by tier (gold/silver/bronze), annotated with tooltips
showing its date range and days-in-tier, and the active chunk is visually
highlighted.

getDashboard now returns referralGrant (activeTier, period bounds, queue).
New ReferralTimelineProgress component renders the segmented bar only when
referralGrant is present. Nothing shown if user has no active referral sub.

Answers Justin's visibility concern — users can now see "Gold for 14 days,
then Bronze for 42" rather than guessing at metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): add review walkthrough checklist

Section 0 lists pre-review setup: migrations, Civitai referral product
inserts, Flipt flag, terms doc placeholders. Sections 1-6 walk the design
docs, code-by-area, manual smoke tests, known gaps, design questions, and
sign-off checklist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): lock effective date + governing law

Effective Date: April 21, 2026.
Governing Law: Delaware (matches main TOS Section 19.2).

Flipt flag referral-program-v2 added upstream in civitai/flipt-state and
enabled by default (commit f09d584 there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referral): generic ReferralRedemption + flatten migrations

Collapses three migrations into one (safe — none applied yet):
- 20260420153258_add_referral_program_v2 (original tables)
- 20260420170000_add_referral_attribution
- 20260421120000_referral_redemption_generic (redemption refactor)

New consolidated migration 20260421120000_add_referral_program_v2 captures
the final target schema in one file.

ReferralRedemption drops tier/durationDays/subscriptionId in favor of:
- rewardType: ReferralRedemptionType enum ('MembershipPerks' only today)
- metadata: JSONB with shape { tier, durationDays, subscriptionId }

Leaves room for future redemption types (BuzzGrant, cosmetic, etc.) without
another migration. Service + dashboard UI updated to read metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(prepaid): exclude referral buzzType from prepaid cron

Both the daily tier-transition sweep and the expired-cleanup pass filter
by product.provider='Civitai'. Referral-granted subs (buzzType='referral')
point to Civitai-provider referral products, so without this guard the
prepaid cron would try to transition them using its prepaid-token logic
and cancel them for having no tokens. advanceReferralSubscriptions owns
those subs instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): link dashboard from buzz + membership pages

ReferralCallout component with two variants:
- full: gradient hero card for the Buzz Dashboard top-of-page
- compact: slim row for the Membership page header

Both flag-gated on features.referralProgramV2 so the card vanishes when
the program is off. Links to /user/referrals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): hidden debug endpoint for UI experimentation

POST /api/testing/referrals guarded by the WEBHOOK_TOKEN header (same
pattern as /api/testing/strikes). Exposes a controlled surface for driving
the referrals dashboard without paying real money, instead of reaching
into the DB directly.

Actions: dump, bind-code, grant-tokens, grant-blue-buzz, enqueue-chunk,
simulate-membership-payment, simulate-buzz-purchase, simulate-chargeback,
settle-all, advance-subs, expire-tokens, reset.

settleImmediately=true on grant-* skips the 7-day wait. settle-all /
advance-subs / expire-tokens fast-forward the relevant cron for one
user. reset wipes a user back to a clean slate.

Typical flow + curl examples documented in docs/features/referral-program.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): document debug endpoint usage

* docs(testing): establish debug-endpoint convention + header doc

Referrals debug endpoint now leads with a block comment enumerating each
action, required params, and a one-line description. That header is the
documentation — agents read the file directly instead of consulting a
wrapper skill.

CLAUDE.md gets a new section describing the convention so the same pattern
propagates to future features. Rule is: drop <feature>.ts under
src/pages/api/testing/, guard with WebhookEndpoint, lead with a block
comment, scope destructive actions to a single userId per call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(referral): 8 staged experiment scenarios + auth fix

Review doc section 7 adds a phase-by-phase experiment plan Justin can
walk through as the feature matures in review. Phase 1 seeded directly
via DB (6 settled Bronze tokens + 500 Blue Buzz display row on user 1)
so the dashboard walk-through works before this branch deploys.

Testing endpoint header updated: auth is ?token= query param (reading
from TokenSecuredEndpoint), not Authorization: Bearer. Doc + curl
examples corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): A/B/C/D dashboard variants with switcher

Page now delegates rendering to one of four variants, picked via ?v= query
param. Parent owns data fetch, redeem mutation, shop modal; variants are
dumb components taking a shared ReferralDashboardVariantProps.

Variants:
- A · Current — baseline minimal (extracted from original referrals.tsx)
- B · Explainer — Buzz-dashboard-style narrative, how-it-works callouts,
  4-step earn/spend breakdown
- C · Gamified — level/rank badge by conversion count, milestone ladder
  as centerpiece, achievement feed framing for recent rewards
- D · Funnel — share → click → signup → first-paid → loyal stages, stages
  we can't track yet labeled "coming soon" instead of fabricated metrics

SegmentedControl at the top lets Justin flip between them side-by-side on
the real dashboard to pick a direction. Switcher removes itself once a
final variant is chosen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): refined gamified dashboard

Picked variant C as the baseline per Justin's feedback and applied the
rework in one pass. All four variants + switcher removed.

Key changes:
- Recruiter rank driven by a unified score (conversions + lifetimeBlueBuzz/1000),
  mapped to named ranks Rookie / Recruit / Advocate / Champion / Legend
  at 0 / 1 / 10 / 50 / 200 points. Separates cleanly from the Blue Buzz
  milestone ladder (milestone bonuses still trigger purely on buzz thresholds).
- Rank card: big name, progress bar to next rank, three supporting stats
  (paid referrals, lifetime Blue Buzz, recruiter score).
- Milestone ladder: full formatted numbers, named milestones, Blue Buzz
  bolt icons throughout, per-row Unlocked/Up next/Locked badge, progress
  bar on the next-up row. Dismissible explainer alert (persists in
  localStorage).
- How-it-works card: 4-step explainer pulled from variant B (Share →
  Earn Tokens → Earn Blue Buzz → Spend Tokens).
- Token Bank: inline 6-offer grid replaces the modal. Redeem happens in
  place. Shop modal removed from the parent page.
- Recent referrals (renamed from Recent activity): kickbacks and recruits
  with amount on the right under status badge, cap 10 with Load more.
- Palette pared back to neutral dark accents + tier colors on the shop
  tiles only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referral): dashboard polish pass

Addresses feedback from the first C rework:

- Bring color back: tier palette on shop headers (bronze/silver/gold
  gradients), accent-colored rank card (Rookie gray / Recruit teal /
  Advocate blue / Champion grape / Legend yellow), blue for Blue Buzz,
  yellow for milestone trophies, green for Settled badges.
- "Recruiter rank" -> "Your rank". Removed the redundant top-right rank
  badge. Rank name is colored by accent and paired with a trophy icon in
  a filled ThemeIcon.
- How it works is now dismissible (localStorage), uses FeatureCard-style
  layout with gradient icon headers (Share blue, Earn Tokens teal, Earn
  Blue Buzz yellow, Spend grape).
- Milestone explainer wording clarified: "Once a friend pays for any
  Membership with your code, every Buzz purchase they make earns you 10%
  back as Blue Buzz" — reflects the actual gate (firstPaidAt, no time
  window). Lifetime Blue Buzz promoted to a standalone pill with a large
  bold number and bolt icon, visible alongside the section title.
- Milestone rows: trophy icon + yellow filled ThemeIcon when unlocked,
  blue light ThemeIcon when Up next, muted when locked. Bonus amount now
  called out in yellow with a bolt icon.
- Token Bank: Spendable / Pending promoted to prominent tiles with icons
  (IconCoin / IconClock) and hint text ("Ready to redeem" / "Settles in 7
  days"). Shop regrouped by tier — 3 tier cards, each containing the 14d
  and 30d options as rows instead of 6 flat tiles.
- Recent referrals: big reward number with tier-colored icon per row.
- A few iconography upgrades across stats (IconSparkles for Recruiter
  Score, IconRocket for "Next rank" label).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(referral): blue-buzz bonus color, filled bolt, rank stats as cards

- Milestone bonus amounts + unlocked trophy/badge now blue (Blue Buzz
  color), not yellow. Yellow was misleading since bonuses pay in Blue
  Buzz, not yellow Buzz.
- Swapped IconBolt -> IconBoltFilled across the dashboard. Civitai never
  uses the outline variant elsewhere.
- Rank stats converted from a divider-separated left-aligned Group to a
  3-column Grid of RankStatCard tiles (icon block + label + value),
  matching the Token Bank / milestone pill styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referrals): rescale recruiter score for granular progress

- 1 point per Blue Buzz, 1,000 per paid referral month
- Thresholds: Rookie 0, Recruit 1k, Advocate 10k, Champion 50k, Legend 200k
- 500 BB now shows halfway to Recruit instead of stuck at 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): restructure dashboard per feedback

- Move How It Works above Rank card
- Move referral timeline under Token Bank
- Token Bank: tier-colored accent header (no dark text on gradient),
  drop meaningless sparkles icon, add divider between duration rows,
  keep button filled when affordable / outlined default when not
- Token Bank subtitle: "Spend referral tokens on Membership perks."
- Add dismissible info alert describing token earning mechanics
- Migrate kickback + how-it-works + token-bank dismissals from
  localStorage to user-level dismissedAlerts (trpc.user.dismissAlert)
- Remove icon on Recent referrals header (only card with one)
- Simplify HowStep #4 copy (drop tier-queue phrasing)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): round 2 UI polish per feedback

- Rank card: progress bar uses next-rank color so it's visible
  against the subtle backdrop (previously gray on gray)
- Token Bank alert: switch color from orange to blue to match
  Blue Buzz milestone alert
- Tier cards: full-width dividers between duration rows
- Spendable/Pending tiles: drop hint text, add info tooltip
  (IconInfoCircle) next to label
- Recent referrals:
  - Add "Settles {date}" for Pending rows
  - Blue Buzz amounts in blue + bolt icon (drop "Blue Buzz" text)
  - Token amounts show coin icon (drop "tokens" text)
  - Icon backgrounds match tier color (bronze/silver/gold)
- Milestone bonus: bolt icon before amount (+[bolt]{num} bonus)
- How It Works cards: FeatureCards-style gradient header + circular
  icon to match Buzz dashboard style
- Timeline: move tier-queue note to bottom, style as crypto-deposit
  info row (IconInfoCircle + dimmed text)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): round 3 polish — premium code block + stats + spotlight

- Code block redesigned crypto-deposit style: gradient accent bar,
  spotlight hover, monospace code in a chip with ActionIcon copy,
  gradient "Copy share link" button, subtle background wash, socials
  under a divider with uppercase section label
- Blue Buzz milestones: replace lifetime-earned chip with Earned +
  Pending stat blocks matching rank card / token bank pattern
- Unify RankStatCard + TokenTile into single StatBlock (optional
  tooltip)
- Add spotlight hover effect to How It Works cards
- Rewrite How It Works copy: shorter, less dense, especially
  step 2 which was number-heavy
- Move Program Terms link to footer of the page

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): scope spotlight, relocate copy-link, add skeletons

- HowStep cards: spotlight now confined to the colored header (was
  spanning the whole card)
- Referral code block: drop the out-of-place top-right "Copy share
  link" button; move it into the Share It row as the first action
  (still gradient-styled, compact-sm to match siblings)
- Rank card: remove the radial glow accent — not used elsewhere in
  the dashboard, looked out of place above the other cards
- Replace loading spinner on /user/referrals with a full-dashboard
  skeleton (matches section structure: code, how-it-works, rank,
  milestones, token bank, recent referrals)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): tier perks popover, yellow tokens, premium rank card

- Shop tier cards: spotlight hover on the Paper, info bobble next
  to the tier title that opens a Popover with PlanBenefitList
  (monthly Buzz + default perks filtered by tier/buzzType). On green
  servers the popover also links out to /pricing for full details.
- Subscriptions/PlanBenefitList: rename default benefit
  "Generate mature content with Blue Buzz" →
  "Unrestricted generation with Blue Buzz" (appears on red/yellow
  servers only). Affects the referrals popover and the pricing page.
- Unify token color to yellow across the dashboard:
  - Token Bank Spendable stat: green → yellow
  - Recent referrals token icon: orange-5 → yellow-5
  - Shop card cost icon: default → yellow-5
- Redemption history reward format: "−1 tok" → "−[coin] 1"
- Rank card: redesigned premium style with spotlight, rank-colored
  gradient accent bar, subtle tinted background wash (matches the
  referral code block treatment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): round 6 — inline value icons, bg fixes, popover portal

- StatBlock gains optional valueIcon prop rendered inline before
  the number; outer icon is now decorative only
- Blue Buzz milestones:
  - Earned: green accent, IconCircleCheck outer, IconBoltFilled inline
  - Pending: IconBoltFilled inline (outer IconClock unchanged)
- Lifetime Blue Buzz (rank card): outer icon → IconHistory, inline
  IconBoltFilled
- Token Bank:
  - Spendable: green accent, IconCircleCheck outer, IconCoin inline
  - Pending: IconCoin inline (outer IconClock unchanged)
- TierPerksPopover: `withinPortal` so the Gold popover is no longer
  clipped by the card's `overflow: hidden`
- Recent referrals: the leading "+" stays white regardless of
  reward color
- ReferralCodeBlock + RankCard: apply light-dark background so they
  stop blending with the page body in dark mode

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(referrals): points-driven milestones + auto-backfill

Replace blue-buzz-driven milestone progression with a unified Referral
Points metric: 1 pt per Blue Buzz earned + 1k/2.5k/5k per paid Bronze/
Silver/Gold referral month (≈10% of each tier's monthly Buzz value).

- constants.referrals.pointsPerTierMonth defines the tier weights
- computeLifetimeReferralPoints sums settled buzz + tier-weighted months
- awardMilestones now drives off lifetime points (same thresholds:
  1k/10k/50k/200k/1M)
- getReferrerBalance returns lifetimePoints + pendingPoints alongside
  the existing token/buzz fields (plus nextTokenExpiresAt and
  expiringSoonTokens for the use-them-or-lose-them UI)
- getDashboard fires awardMilestones(userId) fire-and-forget so a user
  whose lifetime points crossed a threshold under the new scoring gets
  the bonus written + payed out on next page load (idempotent via the
  existing unique (userId, threshold) constraint)
- getDashboard exposes activeMembership so the redeem confirm modal can
  warn before stacking on top of an existing paid plan
- getTierBonuses now returns rewardsMultiplierByTier and
  purchasesMultiplierByTier (skipping referral-grantable placeholder
  products so we reflect what real subscribers get); coerces the loose
  JSON multiplier values to number to avoid string surprises
- Testing endpoint grant-tokens defaults tokenAmount to the tier's
  canonical 1/2/3 when omitted so seeded data matches real flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(referrals): dashboard polish + flag gate

Big consolidated UI pass after Justin's feedback rounds 7-10.

Page + content
- pages/user/referrals.tsx: gated server-side on
  features.referralProgramV2 -> notFound; meta title aligned with
  buzz-dashboard pattern (Civitai | Refer & earn); skeleton fallback
  while data loads
- static-content/referrals/terms.md: frontmatter moved above the
  HTML placement comment so gray-matter parses the title correctly
  (terms page now shows the right title + description)

Dashboard
- "Refer & earn" hero with IconGift ThemeIcon; subtitle tightened
- Premium ReferralCodeBlock: plain left side (gray-50/dark white-3%),
  spotlight + subtle gradient on the right Share It panel, gradient
  divider between, share buttons natural compact-sm
- Friends-get blurb under the code: "Friends get 25% bonus Blue Buzz
  on their first Membership month"
- HowStep cards spotlight scoped to the colored header; rewritten
  copy; "Token Bank" -> "Token Shop" everywhere (title, step 4,
  alert key)
- Rank card: premium style w/ rank-colored gradient bar + spotlight,
  fallback default text color when rank is gray (rookie) so it reads
  in light mode
- Milestones renamed -> "Milestones"; driven by lifetimePoints with
  green Earned (IconCircleCheck) + gray Pending (IconClock) stat
  blocks; inline blue bolt next to BB amounts, violet star next to
  point counts; unlocked thresholds show a fully filled progress bar
- Token Shop: spendable (green) + pending (gray) stats with inline
  yellow coin icons; per-tier ShopTierCard with spotlight, tier-
  colored accent header, info popover that opens PlanBenefitList
  (tier-filtered), full-width dividers between duration rows; redeem
  triggers an openConfirmModal when the user has an active paid
  membership
- Spendable stat shows an IconAlertTriangle to the right of the
  number when tokens expire within 30 days; popover explains how
  many and earliest expiry
- Recent referrals: rows with bg-gray-50/dark white-3%, tier-colored
  icon backgrounds (bronze/silver/gold), settles-on date for pending,
  blue bolt + amount layout for BB, yellow coin + amount for tokens
- Redemption history: "−1 tok" replaced with "− [coin] 1"
- Tooltips switched to color="dark" so they stop reading white in
  light mode
- Scoring details popover (with table) replaces the inline scoring
  text; same popover reused on the Points stat block via an
  UnstyledButton info bobble that matches the rest of the dashboard
- Recruiter Score renamed -> Referral Score, IconAward outer +
  IconStarFilled inline
- Skeleton mirrors all of the above so the loading state matches
  the new layout
- ReferralCallout (Buzz dashboard entry): tileCard-style background
  matching neighboring cards; gift icon; copy without slashed tier
  list
- ReferralTimelineProgress: dark-color tooltips; tier-queue note
  moved below the bars in crypto-deposit info-row style
- dashboard.types.ts: computeRecruiterScore now passes through
  lifetimePoints (rank thresholds unchanged: 0/1k/10k/50k/200k)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(stripe): inline referral code + percent/multiplier perks copy

Move the referral-code entry to Stripe's hosted checkout via
custom_fields so it lives "in the same modal as checkout" (per
feedback: the previous pre-checkout banner felt awkward).

- createSubscribeSession adds a single optional text custom_field
  ("Referral code (optional)", key=ref_code). Cookie-driven refCode
  still flows through subscription metadata as before.
- checkout.session.completed handler (subscription branch) reads
  session.custom_fields[].text.value for ref_code, uppercases/trims,
  and patches the new Subscription's metadata. The existing
  manageInvoicePaid -> bindReferralCodeForUser /
  recordMembershipPaymentReward flow then picks it up unchanged.
- pages/pricing: drop the standalone ReferralCheckoutBanner since
  the field now lives on Stripe's checkout itself.

Cross-cutting perks copy:
- PlanBenefitList: "Generate mature content with Blue Buzz" ->
  "Unrestricted generation with Blue Buzz"; remove negative mx
  on the section divider so it stops blowing out of popovers.
- getPlanDetails: shared formatBoostCopy helper renders perk
  multipliers as "{pct}% bonus..." below 2x and "{n}x ..." at or
  above 2x; applied to both rewards and purchases multipliers; drop
  the isGreen-only gate so red shows the purchase multiplier when
  the tier metadata defines one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(referrals): correctness + abuse hardening from external review

External Gemini review of the reward distribution paths surfaced a
batch of correctness, race, and abuse issues. All addressed in this
commit, with vitest coverage. The dashboard copy + token-earning
popover from the same round are bundled in.

Backend fixes
- recordMembershipPaymentReward: open a $transaction and SELECT … FOR
  UPDATE the UserReferral row before reading paidMonthCount /
  firstPaidAt. Two concurrent invoice.paid webhooks for the same
  referee can no longer both create a RefereeBonus or both increment
  past the cap.
- manageInvoicePaid (stripe webhook): if subscription_details.metadata
  is empty, fall back to fetching the parent Checkout Session and
  reading custom_fields[].text.value for ref_code. Stripe doesn't
  guarantee checkout.session.completed lands before invoice.paid; this
  catches the "first invoice arrives first" race.
- ReferralReward.points column (new) snapshots the tier's point value
  at write time. computeLifetimeReferralPoints + getReferrerBalance
  now sum that column instead of dynamically multiplying historical
  membership token counts by the current pointsPerTierMonth.
  Re-tuning the constants no longer retroactively unlocks milestones.
  Migration backfills existing rows with their tier-canonical points.
- payment_intent.succeeded: drop the swallowed .catch(() => null) on
  recordBuzzPurchaseKickback so a transient DB error propagates and
  Stripe retries the webhook. Idempotency is already guaranteed by
  the @@unique(kind, sourceEventId) constraint.
- revokeForChargeback: per-referee tx that locks UserReferral via
  $queryRaw FOR UPDATE, decrements paidMonthCount by the count of
  revoked MembershipToken rows, and clears firstPaidAt to null when
  the count drops to 0. Closes the refund-and-keep-buzz-kickbacks
  abuse path. Lock prevents two concurrent chargebacks for the same
  referee from racing the read-modify-write.
- grantReferralSubscription: lock the CustomerSubscription row at the
  start of the tx so a concurrent advance-cron run can't overwrite a
  newly-appended queue entry.
- advanceReferralSubscriptions: run each sub through its own
  $transaction with FOR UPDATE on the row, re-check currentPeriodEnd
  inside the lock so a redemption that bumped the period out doesn't
  get clobbered.
- grantReferralSubscription enforces constants.referrals.maxQueuedDays
  (365). A hot referrer can't queue years of perks (Stripe's date max
  is 2038). Returns a user-facing error so redeemTokens rolls back
  and the spent tokens are not consumed.
- computeLifetimeReferralPoints + getReferrerBalance.lifetimePoints
  include Expired status — a token expiring shouldn't pull the user's
  lifetime points down (or boot them out of a milestone).

Dashboard copy
- Milestones info alert reworked to match how kickbacks actually work:
  earn points per paid Membership month + 10% of any Buzz the friend
  buys after joining. Cross a milestone for a bonus.
- Token Shop info alert simplified: "Earn tokens per Bronze (1 token),
  Silver (2 tokens), or Gold (3 tokens) month, up to three months
  per friend."
- New TokenEarningsPopover wired to the Spendable stat info bobble
  (mirrors the ScoringDetailsPopover pattern). Permanent reference for
  per-tier token amounts + the 3-month-per-friend cap + 90-day expiry,
  available even after the dismissable alert is closed.

Tests
- 35 vitest cases (was 24); all green.
- New: race-protected first-payment via locked paidMonthCount, cap
  enforcement after lock, inline RefereeBonus settle (success +
  buzz-grant-failure revert), revokeForChargeback decrement +
  firstPaidAt clear, RefereeBonus revoke does not touch UserReferral,
  paid months remain → firstPaidAt stays, FOR UPDATE used in
  revokeForChargeback (no findUnique fallback), advance bails when
  currentPeriodEnd is bumped out by a parallel redeem, points
  snapshot is immune to config changes, lifetime aggregate filter
  includes Expired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:11:28 -06:00
..