Files
Momento/docs/3-4-host-pays-session-logic.md
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
2026-05-16 12:59:30 +00:00

16 KiB
Raw Permalink Blame History

Story 3.4: The "Host-Pays" Session Logic

Status: review

Story

As a host, I want my guests' AI actions inside my Canvas session to be billed to my account, so that my guests never hit a paywall while collaborating with me.

Epic: Epic 3 — The SaaS Commercial Engine (Monetization & API Cost Protection)
FR coverage: FR15 (Host-Pays Session Billing Logic), NFR-P3 (routing must evaluate Host-Pays within 50ms — satisfied if billing resolution is sync DB read, no extra HTTP).


Acceptance Criteria

  1. [AC1] Billing owner resolution: For any collaborative Brainstorm session, a single function getBillingOwner(sessionId, requestingUserId) returns BrainstormSession.userId (the session host). Guests never become the billing owner.
  2. [AC2] Guest AI → host quota: When a guest (participant with role !== 'host' or access via share/public link) triggers an AI-backed Brainstorm action, checkEntitlementOrThrow and incrementUsageAsync use the host's userId, not the guest's.
  3. [AC3] Host AI → host quota: When the host triggers the same actions, billing still uses the host's quota (same code path — no special case).
  4. [AC4] Covered AI surfaces (minimum):
    • POST /api/brainstorm — initial wave generation (session create)
    • POST /api/brainstorm/[sessionId]/expand — wave expansion
    • POST /api/brainstorm/[sessionId]/manual-idea — vector context search + async title/description enrichment
  5. [AC5] HTTP 402 on host exhaustion: If the host's quota is exhausted, the API returns 402 with body compatible with existing QuotaExceededError.toJSON() plus:
    • billingOwnerId (host user id)
    • triggeredByUserId (actor who clicked)
    • isGuestActor: boolean Guests must not receive a 402 implying their personal quota is exhausted.
  6. [AC6] Guest quota untouched: A guest on BASIC tier with personal Discovery Pack remaining can collaborate freely until the host's quota is hit; verify with test: guest triggers expand → host Redis key increments, guest keys unchanged.
  7. [AC7] Ordering: Entitlement check runs before any paid AI call (LLM generateText, embedding generation used for brainstorm context). Quota increment runs after successful AI completion (same fire-and-forget pattern as Story 3.1).
  8. [AC8] Alignment with 3.3 fallback: Provider fallback (withAiProviderFallback) must attribute usage to billingOwnerId, not requestingUserId. Do not add fallback inside routes — wrap provider execution only after entitlement passes.
  9. [AC9] Regression: Stories 3.13.3 behavior for non-brainstorm routes (/api/chat, /api/ai/tags, etc.) unchanged. Existing resolveAiContextUserId guest note scoping remains intact.

Tasks / Subtasks

  • Task 1: Billing owner API (AC: #1)
    • Subtask 1.1: Add getBillingOwner(sessionId, requestingUserId): Promise<string> in lib/brainstorm-collab.ts — load session, throw if missing, return session.userId
    • Subtask 1.2: Unit test: host actor → returns host id; guest actor → returns host id (not guest id)
  • Task 2: Brainstorm feature keys in entitlements (AC: #2, #6)
    • Subtask 2.1: Extend VALID_FEATURES in lib/quota-utils.ts: brainstorm_create, brainstorm_expand, brainstorm_enrich
    • Subtask 2.2: Add tier limits in lib/entitlements.ts TIER_LIMITS (suggested MVP — tune with product):
      • BASIC: brainstorm_create: 1 (lifetime/month per existing product intent), brainstorm_expand: 10, brainstorm_enrich: 20 per period
      • PRO/BUSINESS: generous monthly limits or map brainstorm_* to shared chat pool — document chosen mapping in Dev Agent Record
    • Subtask 2.3: Extend QuotaExceededError optional fields: billingOwnerId?, triggeredByUserId?, isGuestActor? — include in toJSON() for frontend
  • Task 3: Wire API routes (AC: #4, #5, #7)
    • Subtask 3.1: app/api/brainstorm/route.ts — before getTagsProvider / generateText, checkEntitlementOrThrow(userId, 'brainstorm_create'); on success incrementUsageAsync(userId, 'brainstorm_create')
    • Subtask 3.2: expand/route.ts — resolve billingOwnerId = await getBillingOwner(...); check/increment brainstorm_expand; wrap LLM with withAiProviderFallback('tags', config, ...) if not already; catch QuotaExceededError → 402 with guest metadata
    • Subtask 3.3: manual-idea/route.ts — check brainstorm_enrich before embedding + before scheduling enrichAsync; increment once per successful enrichment (not on idea create alone); 402 on host exhaustion before returning 201 if enrichment is required — product choice: block create vs allow raw idea without AI (recommend: check before async enrich; if fail, still return 201 but emit idea:ai_failed with quota_exceeded reason — document in implementation)
    • Subtask 3.4: Map QuotaExceededError in all three routes to NextResponse.json({ ...err.toJSON(), billingOwnerId, triggeredByUserId, isGuestActor }, { status: 402 })
  • Task 4: Frontend guest/host messaging (AC: #5)
    • Subtask 4.1: In hooks/use-brainstorm.ts — on 402 from expand/manual-idea, surface toast/modal: guest sees "Session host has reached their AI limit"; host sees standard upgrade CTA
    • Subtask 4.2: i18n keys in locales/en.json + fr.json — no hardcoded UI strings
  • Task 5: Tests (AC: #6, #9)
    • Subtask 5.1: tests/unit/brainstorm-billing.test.ts — mock prisma session + redis; guest expand bills host
    • Subtask 5.2: Run npm run test:unit -- tests/unit/brainstorm-billing.test.ts tests/unit/entitlements.test.ts

Dev Notes

Epic context

Story Relevance to 3.4
3.1 Redis checkEntitlementOrThrow / incrementUsageAsync — reuse; change which userId is passed
3.2 Provider routing — unchanged; billing user ≠ routing config user
3.3 Fallback must not mis-attribute usage — pass billingOwnerId into increment after fallback success
3.5 BYOK bypass on quota — out of scope; add // Story 3.5: skip quota when host has active BYOK seam only
3.6 Stripe tier upgrades — host tier drives limits

Critical brownfield reality (READ BEFORE CODING)

Partial host-pays already exists for note context, NOT for billing:

// resolveAiContextUserId — guests use host's notes for RAG
export async function resolveAiContextUserId(sessionId, requestingUserId) {
  // ...
  if (isHost) return { aiUserId: requestingUserId, isGuest: false, publicNoteIds: null }
  return { aiUserId: session.userId, isGuest: true, publicNoteIds: ... }
}

Gap: Brainstorm AI routes call no checkEntitlementOrThrow today:

Route AI operations today Bills
POST /api/brainstorm getTagsProvider + generateText Nobody
POST .../expand embedding (host only) + generateText Nobody
POST .../manual-idea embedding + async generateText enrich Nobody

getBillingOwner does not exist in codebase — only in memento-note/docs/byok-billing-patch-v3.md.

Data model

  • Host = BrainstormSession.userId (creator), NOT BrainstormParticipant.role === 'host' alone — always use session owner for billing even if participant table is inconsistent.
  • Guest = any requestingUserId !== session.userId with valid participant/share access (already enforced by verifyParticipant / resolveAccessRole).
// lib/brainstorm-collab.ts
export async function getBillingOwner(
  sessionId: string,
  requestingUserId: string,
): Promise<{ billingOwnerId: string; isGuestActor: boolean }> {
  const session = await prisma.brainstormSession.findUnique({
    where: { id: sessionId },
    select: { userId: true },
  })
  if (!session) throw new Error('Session not found')
  return {
    billingOwnerId: session.userId,
    isGuestActor: session.userId !== requestingUserId,
  }
}

// Usage in expand/route.ts (before LLM)
const { billingOwnerId, isGuestActor } = await getBillingOwner(sessionId, session.user.id)
await checkEntitlementOrThrow(billingOwnerId, 'brainstorm_expand')
// ... run AI with withAiProviderFallback ...
incrementUsageAsync(billingOwnerId, 'brainstorm_expand')

Do not conflate aiUserId (note/RAG scope) with billingOwnerId (quota). Today both equal session.userId for guests — keep two functions for clarity and 3.5 BYOK.

Files — expected touch list

NEW

  • memento-note/tests/unit/brainstorm-billing.test.ts

UPDATE

  • memento-note/lib/brainstorm-collab.tsgetBillingOwner
  • memento-note/lib/quota-utils.ts — feature keys
  • memento-note/lib/entitlements.ts — tier limits + extended QuotaExceededError
  • memento-note/app/api/brainstorm/route.ts
  • memento-note/app/api/brainstorm/[sessionId]/expand/route.ts
  • memento-note/app/api/brainstorm/[sessionId]/manual-idea/route.ts
  • memento-note/hooks/use-brainstorm.ts
  • memento-note/locales/en.json, fr.json

READ BEFORE MODIFY

  • memento-note/lib/brainstorm-collab.tsresolveAiContextUserId, verifyParticipant
  • memento-note/lib/entitlements.tscheckEntitlementOrThrow, fail-open Redis behavior
  • memento-note/lib/ai/fallback.ts — Story 3.3 wrapper (no quota logic inside)
  • memento-note/app/api/chat/route.ts — reference 402 handling pattern

DO NOT MODIFY (unless broken by typing)

  • socket-server.ts — no AI billing today; socket quota events are optional follow-up
  • finalize, dismiss, convert, export routes — no LLM

Product / scope boundaries

In scope for 3.4: Host-pays quota attribution on Brainstorm AI paths.

Explicitly out of scope (later stories / docs):

  • BrainstormContextPool and “1 brainstorm/month on BASIC” session caps (byok-billing-patch-v3.md §5) — do not implement full pool unless PM confirms; minimal brainstorm_create Redis counter is enough for AC
  • BYOK quota bypass — Story 3.5
  • Stripe tier sync — Story 3.6
  • Real-time socket error:quota_exceeded — optional; HTTP 402 is sufficient for MVP
  • Billing LLM token $ cost to LLMCallLog — aspirational in patch doc

UX requirements (FR15 / PRD journey)

From PRD: "Collaborator (Guest): Can interact and generate ideas, but AI queries are routed through the Host's quota."

  • Guest must never see “Upgrade your plan” for their BASIC quota when the host still has credits.
  • When host is exhausted, guest sees collaborative framing: host must upgrade or add BYOK (3.5).
  • Reuse existing paywall modal component if present (usage-meter / upgrade flow from 3.1) with billingOwnerId prop when isGuestActor.

Testing standards

  • Vitest; mock prisma.brainstormSession.findUnique and Redis via entitlements mocks.
  • Assert Redis key usage:{hostId}:brainstorm_expand:YYYY-MM increments when guest calls expand.
  • Assert guest's usage:{guestId}:brainstorm_expand:* unchanged.

Dev Agent Guardrails

Technical requirements

  • NFR-SC2: getBillingOwner is one Prisma findUnique by primary key — keep sync with session fetch already done in routes (avoid duplicate query: compute billing owner from loaded brainstormSession.userId when session is already loaded).
  • Fail-open: If Redis down, entitlements fail-open allows AI (3.1 behavior) — do not change global policy in this story.
  • 402 body: Must remain parseable by existing chat/tags clients; only add fields.
  • Async manual-idea enrich: Race: two guests enqueue enrich — both bill host per successful enrich (acceptable); use one increment per completed enrich.

Architecture compliance

  • Brownfield Next.js App Router under memento-note/.
  • Host-pays is a billing concern — lives in brainstorm-collab.ts + route glue, not in router.ts.
  • i18n: all new UI strings via t('brainstorm.hostQuotaExceeded') etc.

Library / framework requirements

  • Reuse Story 3.1 entitlements — no new Redis client.
  • Reuse Story 3.3 withAiProviderFallback for LLM calls in brainstorm routes (lane 'tags' matches current getTagsProvider usage).

File structure requirements

  • getBillingOwner next to resolveAiContextUserId in lib/brainstorm-collab.ts.
  • Tests under tests/unit/.

Previous Story Intelligence

Source: docs/3-3-smart-routing-fallback.md

  • Fallback on 429/5xx; withAiProviderFallback integrated in chat, tags, embeddings.
  • Explicit note: "3.4 — Host-pays billing context; fallback must not mis-attribute token usage."
  • skipSystemFallback stub for BYOK (3.5).
  • Brainstorm routes deferred for fallback in 3.3 — 3.4 implementer should add fallback + billing together on expand/manual-idea.

Source: docs/3-1-freemium-quota-tracking.md

  • checkEntitlementOrThrow before AI; incrementUsageAsync after.
  • 402 / QuotaExceededError pattern established.
  • Feature keys today: semantic_search, auto_tag, auto_title, reformulate, chat only.

Source: docs/3-2-custom-llm-router.md

  • getTagsProvider used for brainstorm text generation — keep lane tags for fallback wrapper.

Git Intelligence Summary

Commit Insight
1fcea6e Recent brainstorm + embedding work — expand/manual-idea paths active
41596c2 OpenRouter key resolution — billing owner irrelevant to provider keys (system config)
195e845 Security patterns — guest note scoping already hardened

Latest Technical Information

  • Entitlements pattern (2026-05): Monthly Redis keys usage:{userId}:{feature}:{YYYY-MM}; extend with brainstorm_* features rather than overloading chat to preserve Discovery Pack semantics.
  • Prisma: BrainstormSession.userId is canonical host; participant role: 'host' should match but do not rely on it for billing.

Project Context Reference

Document Use
docs/epics.md Story 3.4 AC + FR15
docs/prd.md Host-Pays journey, RBAC matrix
memento-note/docs/byok-billing-patch-v3.md §3 Host-Pays (aspirational — implement AC scope only)
docs/3-1-freemium-quota-tracking.md Entitlements API
docs/3-3-smart-routing-fallback.md Fallback + usage attribution
docs/sprint-status.yaml Tracking

Dev Agent Record

Agent Model Used

Composer (Cursor)

Debug Log References

  • npm run test:unit -- tests/unit/brainstorm-billing.test.ts tests/unit/entitlements.test.ts — 23 passed

Completion Notes List

  • Added getBillingOwner, billingOwnerFromSession, and checkSessionEntitlementOrThrow for host-pays quota attribution.
  • Tier limits: BASIC brainstorm_create: 1, brainstorm_expand: 10, brainstorm_enrich: 20; PRO/BUSINESS/ENTERPRISE dedicated monthly caps (not mapped to chat pool).
  • Routes: create/expand return 402 on host exhaustion; manual-idea returns 201 with raw idea and emits idea:ai_failed + quota_exceeded when host enrich quota is exhausted (no embedding).
  • withAiProviderFallback('tags', …) on create, expand, and async enrich paths.
  • Frontend: BrainstormQuotaError + brainstormQuotaMessageKey; toast in canvas; i18n quotaGuest / quotaHost (en, fr).

File List

  • memento-note/lib/brainstorm-collab.ts
  • memento-note/lib/brainstorm-quota-client.ts (new)
  • memento-note/lib/quota-utils.ts
  • memento-note/lib/entitlements.ts
  • memento-note/app/api/brainstorm/route.ts
  • memento-note/app/api/brainstorm/[sessionId]/expand/route.ts
  • memento-note/app/api/brainstorm/[sessionId]/manual-idea/route.ts
  • memento-note/hooks/use-brainstorm.ts
  • memento-note/components/brainstorm/brainstorm-canvas.tsx
  • memento-note/locales/en.json
  • memento-note/locales/fr.json
  • memento-note/tests/unit/brainstorm-billing.test.ts (new)

Change Log

  • 2026-05-15: Implemented host-pays session billing for brainstorm AI (FR15).

Story Completion Status

  • Story ID: 3.4
  • Story Key: 3-4-host-pays-session-logic
  • File: docs/3-4-host-pays-session-logic.md
  • Status: review
  • Completion Note: Host-pays quota wired on brainstorm create/expand/enrich; unit tests passing.