- 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
16 KiB
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
- [AC1] Billing owner resolution: For any collaborative Brainstorm session, a single function
getBillingOwner(sessionId, requestingUserId)returnsBrainstormSession.userId(the session host). Guests never become the billing owner. - [AC2] Guest AI → host quota: When a guest (participant with
role !== 'host'or access via share/public link) triggers an AI-backed Brainstorm action,checkEntitlementOrThrowandincrementUsageAsyncuse the host'suserId, not the guest's. - [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).
- [AC4] Covered AI surfaces (minimum):
POST /api/brainstorm— initial wave generation (session create)POST /api/brainstorm/[sessionId]/expand— wave expansionPOST /api/brainstorm/[sessionId]/manual-idea— vector context search + async title/description enrichment
- [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: booleanGuests must not receive a 402 implying their personal quota is exhausted.
- [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.
- [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). - [AC8] Alignment with 3.3 fallback: Provider fallback (
withAiProviderFallback) must attribute usage tobillingOwnerId, notrequestingUserId. Do not add fallback inside routes — wrap provider execution only after entitlement passes. - [AC9] Regression: Stories 3.1–3.3 behavior for non-brainstorm routes (
/api/chat,/api/ai/tags, etc.) unchanged. ExistingresolveAiContextUserIdguest note scoping remains intact.
Tasks / Subtasks
- Task 1: Billing owner API (AC: #1)
- Subtask 1.1: Add
getBillingOwner(sessionId, requestingUserId): Promise<string>inlib/brainstorm-collab.ts— load session, throw if missing, returnsession.userId - Subtask 1.2: Unit test: host actor → returns host id; guest actor → returns host id (not guest id)
- Subtask 1.1: Add
- Task 2: Brainstorm feature keys in entitlements (AC: #2, #6)
- Subtask 2.1: Extend
VALID_FEATURESinlib/quota-utils.ts:brainstorm_create,brainstorm_expand,brainstorm_enrich - Subtask 2.2: Add tier limits in
lib/entitlements.tsTIER_LIMITS(suggested MVP — tune with product):- BASIC:
brainstorm_create: 1(lifetime/month per existing product intent),brainstorm_expand: 10,brainstorm_enrich: 20per period - PRO/BUSINESS: generous monthly limits or map
brainstorm_*to sharedchatpool — document chosen mapping in Dev Agent Record
- BASIC:
- Subtask 2.3: Extend
QuotaExceededErroroptional fields:billingOwnerId?,triggeredByUserId?,isGuestActor?— include intoJSON()for frontend
- Subtask 2.1: Extend
- Task 3: Wire API routes (AC: #4, #5, #7)
- Subtask 3.1:
app/api/brainstorm/route.ts— beforegetTagsProvider/generateText,checkEntitlementOrThrow(userId, 'brainstorm_create'); on successincrementUsageAsync(userId, 'brainstorm_create') - Subtask 3.2:
expand/route.ts— resolvebillingOwnerId = await getBillingOwner(...); check/incrementbrainstorm_expand; wrap LLM withwithAiProviderFallback('tags', config, ...)if not already; catchQuotaExceededError→ 402 with guest metadata - Subtask 3.3:
manual-idea/route.ts— checkbrainstorm_enrichbefore embedding + before schedulingenrichAsync; 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 emitidea:ai_failedwithquota_exceededreason — document in implementation) - Subtask 3.4: Map
QuotaExceededErrorin all three routes toNextResponse.json({ ...err.toJSON(), billingOwnerId, triggeredByUserId, isGuestActor }, { status: 402 })
- Subtask 3.1:
- 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
- Subtask 4.1: In
- 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
- Subtask 5.1:
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), NOTBrainstormParticipant.role === 'host'alone — always use session owner for billing even if participant table is inconsistent. - Guest = any
requestingUserId !== session.userIdwith valid participant/share access (already enforced byverifyParticipant/resolveAccessRole).
Recommended implementation shape
// 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—getBillingOwnermemento-note/lib/quota-utils.ts— feature keysmemento-note/lib/entitlements.ts— tier limits + extendedQuotaExceededErrormemento-note/app/api/brainstorm/route.tsmemento-note/app/api/brainstorm/[sessionId]/expand/route.tsmemento-note/app/api/brainstorm/[sessionId]/manual-idea/route.tsmemento-note/hooks/use-brainstorm.tsmemento-note/locales/en.json,fr.json
READ BEFORE MODIFY
memento-note/lib/brainstorm-collab.ts—resolveAiContextUserId,verifyParticipantmemento-note/lib/entitlements.ts—checkEntitlementOrThrow, fail-open Redis behaviormemento-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-upfinalize,dismiss,convert,exportroutes — 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):
BrainstormContextPooland “1 brainstorm/month on BASIC” session caps (byok-billing-patch-v3.md§5) — do not implement full pool unless PM confirms; minimalbrainstorm_createRedis 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) withbillingOwnerIdprop whenisGuestActor.
Testing standards
- Vitest; mock
prisma.brainstormSession.findUniqueand Redis via entitlements mocks. - Assert Redis key
usage:{hostId}:brainstorm_expand:YYYY-MMincrements when guest calls expand. - Assert guest's
usage:{guestId}:brainstorm_expand:*unchanged.
Dev Agent Guardrails
Technical requirements
- NFR-SC2:
getBillingOwneris one PrismafindUniqueby primary key — keep sync with session fetch already done in routes (avoid duplicate query: compute billing owner from loadedbrainstormSession.userIdwhen 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 inrouter.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
withAiProviderFallbackfor LLM calls in brainstorm routes (lane'tags'matches currentgetTagsProviderusage).
File structure requirements
getBillingOwnernext toresolveAiContextUserIdinlib/brainstorm-collab.ts.- Tests under
tests/unit/.
Previous Story Intelligence
Source: docs/3-3-smart-routing-fallback.md
- Fallback on 429/5xx;
withAiProviderFallbackintegrated in chat, tags, embeddings. - Explicit note: "3.4 — Host-pays billing context; fallback must not mis-attribute token usage."
skipSystemFallbackstub 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
checkEntitlementOrThrowbefore AI;incrementUsageAsyncafter.- 402 /
QuotaExceededErrorpattern established. - Feature keys today:
semantic_search,auto_tag,auto_title,reformulate,chatonly.
Source: docs/3-2-custom-llm-router.md
getTagsProviderused for brainstorm text generation — keep lanetagsfor 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 withbrainstorm_*features rather than overloadingchatto preserve Discovery Pack semantics. - Prisma:
BrainstormSession.userIdis canonical host; participantrole: '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, andcheckSessionEntitlementOrThrowfor host-pays quota attribution. - Tier limits: BASIC
brainstorm_create: 1,brainstorm_expand: 10,brainstorm_enrich: 20; PRO/BUSINESS/ENTERPRISE dedicated monthly caps (not mapped tochatpool). - Routes: create/expand return 402 on host exhaustion; manual-idea returns 201 with raw idea and emits
idea:ai_failed+quota_exceededwhen host enrich quota is exhausted (no embedding). withAiProviderFallback('tags', …)on create, expand, and async enrich paths.- Frontend:
BrainstormQuotaError+brainstormQuotaMessageKey; toast in canvas; i18nquotaGuest/quotaHost(en, fr).
File List
memento-note/lib/brainstorm-collab.tsmemento-note/lib/brainstorm-quota-client.ts(new)memento-note/lib/quota-utils.tsmemento-note/lib/entitlements.tsmemento-note/app/api/brainstorm/route.tsmemento-note/app/api/brainstorm/[sessionId]/expand/route.tsmemento-note/app/api/brainstorm/[sessionId]/manual-idea/route.tsmemento-note/hooks/use-brainstorm.tsmemento-note/components/brainstorm/brainstorm-canvas.tsxmemento-note/locales/en.jsonmemento-note/locales/fr.jsonmemento-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.