# 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.1–3.3 behavior for non-brainstorm routes (`/api/chat`, `/api/ai/tags`, etc.) unchanged. Existing `resolveAiContextUserId` guest note scoping remains intact. --- ## Tasks / Subtasks - [x] Task 1: Billing owner API (AC: #1) - [x] Subtask 1.1: Add `getBillingOwner(sessionId, requestingUserId): Promise` in `lib/brainstorm-collab.ts` — load session, throw if missing, return `session.userId` - [x] Subtask 1.2: Unit test: host actor → returns host id; guest actor → returns host id (not guest id) - [x] Task 2: Brainstorm feature keys in entitlements (AC: #2, #6) - [x] Subtask 2.1: Extend `VALID_FEATURES` in `lib/quota-utils.ts`: `brainstorm_create`, `brainstorm_expand`, `brainstorm_enrich` - [x] 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** - [x] Subtask 2.3: Extend `QuotaExceededError` optional fields: `billingOwnerId?`, `triggeredByUserId?`, `isGuestActor?` — include in `toJSON()` for frontend - [x] Task 3: Wire API routes (AC: #4, #5, #7) - [x] Subtask 3.1: `app/api/brainstorm/route.ts` — before `getTagsProvider` / `generateText`, `checkEntitlementOrThrow(userId, 'brainstorm_create')`; on success `incrementUsageAsync(userId, 'brainstorm_create')` - [x] 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 - [x] 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) - [x] Subtask 3.4: Map `QuotaExceededError` in all three routes to `NextResponse.json({ ...err.toJSON(), billingOwnerId, triggeredByUserId, isGuestActor }, { status: 402 })` - [x] Task 4: Frontend guest/host messaging (AC: #5) - [x] 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 - [x] Subtask 4.2: i18n keys in `locales/en.json` + `fr.json` — no hardcoded UI strings - [x] Task 5: Tests (AC: #6, #9) - [x] Subtask 5.1: `tests/unit/brainstorm-billing.test.ts` — mock prisma session + redis; guest expand bills host - [x] 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:** ```47:78:memento-note/lib/brainstorm-collab.ts // 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`). ### Recommended implementation shape ```typescript // 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.ts` — `getBillingOwner` - `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.ts` — `resolveAiContextUserId`, `verifyParticipant` - `memento-note/lib/entitlements.ts` — `checkEntitlementOrThrow`, 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.