All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- 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
313 lines
16 KiB
Markdown
313 lines
16 KiB
Markdown
# 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.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<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.
|