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

313 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 3.4: The "Host-Pays" Session Logic
Status: review
<!-- Ultimate context engine analysis completed - comprehensive developer guide created -->
## 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
- [x] Task 1: Billing owner API (AC: #1)
- [x] Subtask 1.1: Add `getBillingOwner(sessionId, requestingUserId): Promise<string>` 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.