From e09af8e6c347e7b3505c69cc9d732e4069d18c40 Mon Sep 17 00:00:00 2001 From: JoachimLK Date: Fri, 7 Aug 2026 18:22:46 +0200 Subject: [PATCH 01/13] Make BYOK an AI feature for Solo and above * **billing:** Restrict bring-your-own-AI-key (BYOK) configurations to Solo-and-above. Free plans now exclusively use the platform-provided OpenRouter key for the AI assistant. * **assistant:** Ship the AI assistant to all plans. Added a lifetime turn-based quota (`FREE_PLAN_CHATBOT_TURN_LIMIT`) for Free-tier workspaces to manage costs while allowing a functional demo. * **budgeting:** Integrate assistant turn usage into the platform-paid budget ledger. Assistant turns are now metered against the same monthly dollar-cap as analysis runs to prevent bypasses. * **UI:** Add `ChatbotQuotaUpsellCard.vue` to transition users who have exhausted their free turns into the Solo plan. * **architecture:** Introduce `ai_usage_event` ledger to record non-analysis AI spend and clarify usage for billing enforcement. --- CHANGELOG.md | 2 + app/components/AppTopBar.vue | 9 +- app/components/ChatbotQuotaUpsellCard.vue | 100 + app/components/FreePlanUpgradeMenu.vue | 6 +- app/components/FreePlanUpsellCard.vue | 19 +- app/components/PublicPricingSection.vue | 10 +- app/composables/useBillingStatus.ts | 1 + app/composables/useChatbotQuota.ts | 46 + app/pages/dashboard/chatbot/[[id]].vue | 254 +- app/pages/dashboard/settings/ai/index.vue | 5 +- app/pages/dashboard/settings/ai/new.vue | 2 +- app/pages/dashboard/settings/billing.vue | 1 + server/api/ai-config/[id].patch.ts | 49 +- server/api/ai-config/index.get.ts | 2 + server/api/ai-config/index.post.ts | 10 +- server/api/billing/status.get.ts | 3 + server/api/chatbot/chat.post.ts | 75 +- server/api/chatbot/conversations/[id].get.ts | 14 +- .../api/chatbot/conversations/[id].patch.ts | 28 +- server/api/chatbot/conversations/index.get.ts | 19 +- .../api/chatbot/conversations/index.post.ts | 31 +- .../migrations/0060_happy_wolfpack.sql | 21 + .../migrations/meta/0060_snapshot.json | 8290 +++++++++++++++++ server/database/migrations/meta/_journal.json | 7 + server/database/schema/app.ts | 50 + server/utils/ai/budget.ts | 181 +- server/utils/ai/platformConfig.ts | 13 +- server/utils/ai/resolveProvider.ts | 73 +- server/utils/ai/usage.ts | 46 + server/utils/billing/plan.ts | 49 +- server/utils/billing/usage.ts | 29 +- server/utils/chatbotAccess.ts | 26 +- server/utils/chatbotConversation.ts | 54 + shared/billing.ts | 37 +- shared/feature-flags.ts | 6 + tests/unit/ai-budget-ledgers.test.ts | 190 + tests/unit/billing-plan-resolution.test.ts | 35 +- tests/unit/chatbot-plan-gate.test.ts | 139 + 38 files changed, 9659 insertions(+), 273 deletions(-) create mode 100644 app/components/ChatbotQuotaUpsellCard.vue create mode 100644 app/composables/useChatbotQuota.ts create mode 100644 server/database/migrations/0060_happy_wolfpack.sql create mode 100644 server/database/migrations/meta/0060_snapshot.json create mode 100644 server/utils/ai/usage.ts create mode 100644 server/utils/chatbotConversation.ts create mode 100644 tests/unit/ai-budget-ledgers.test.ts create mode 100644 tests/unit/chatbot-plan-gate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2f6067..02ea9d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com). Categories: **Add ### Changed +* **billing:** make bring-your-own-AI-key (BYOK) a Solo-and-above capability instead of a free-plan feature — a free org that brought its own key got the uncapped assistant and uncapped shortlists for nothing, which is what the entry plan sells. Only *creating* a config is gated: free orgs that configured a key before this change keep editing and using it, and grandfathered orgs are unaffected (BYOK is their only route to AI at all). * **licensing:** re-open Reqcore as open-core — AGPLv3 for the core app, with a new [`ee/`](ee) directory (Nuxt layer) for paid, cloud-only features under a separate commercial license. Self-hosting is supported again, best-effort and unsupported (see [SELF-HOSTING.md](SELF-HOSTING.md)). Moved the already plan-gated SSO (`ee/server/api/sso/`), org-wide audit log (`ee/server/api/activity-log/index.get.ts`), source-analytics (`ee/server/api/source-tracking/stats.get.ts`), and AI-analysis dashboard (`ee/server/api/ai-analysis/stats.get.ts`) endpoints out of the AGPL tree and into `ee/` so the license split matches what's actually gated; the underlying tables and the ungated candidate timeline/activity feed/tracking-link CRUD stay in core. ### Added +* **assistant:** ship the AI assistant on every plan, on top of the existing `chatbot-experience` rollout flag. It runs on the platform OpenRouter key by default — so an org gets a working assistant without configuring anything — or on the org's own key (BYOK), which is never budget-capped. Free orgs get a lifetime allowance of 20 assistant messages (`AI_FREE_PLAN_CHATBOT_TURN_LIMIT`), metered separately from the free AI-shortlist allowance and surfaced as a meter on the billing page; paid orgs are metered by the monthly AI budget instead. Platform-paid turns are recorded in a new `ai_usage_event` ledger and count against the org's monthly AI budget and the global daily kill-switch alongside analysis runs. Grandfathered orgs stay BYOK-only. * **GDPR retention:** add a shared candidate-retention runner with a daily Nitro task, external cron endpoint, instance-wide emergency switch, quarantine restoration on renewed public engagement, localized administration UI, and computed expiry visibility. * **GDPR erasure:** remove application-linked comments, custom properties, and activity records in addition to the candidate database graph and S3 objects. * **blog:** add Cluster 8 career page articles — pillar (career-page-that-converts) and two supporting articles (career-page-seo, google-for-jobs-structured-data) diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue index 54917581..d7004200 100644 --- a/app/components/AppTopBar.vue +++ b/app/components/AppTopBar.vue @@ -146,9 +146,12 @@ function isNavLocked(item: { feature?: PlanFeature }): boolean { // Items shown only when their feature flag is enabled. Filtered into mainNav // reactively so the gating happens at render time (PostHog flags load async). const flaggedNav = computed(() => { - const items: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; afterLabel: string }> = [] + const items: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; afterLabel: string; feature?: PlanFeature }> = [] + // The flag controls whether the assistant has shipped; `feature` controls + // whether this org's plan includes it. An unentitled org still sees the item + // (with a lock) and lands on the page's upgrade card — same as Timeline. if (showChatbot.value) { - items.push({ label: 'Assistant', to: '/dashboard/chatbot', icon: MessageCircle, exact: false, afterLabel: 'AI Analysis' }) + items.push({ label: 'Assistant', to: '/dashboard/chatbot', icon: MessageCircle, exact: false, afterLabel: 'AI Analysis', feature: 'chatbot' }) } return items }) @@ -159,7 +162,7 @@ const navItems = computed(() => { const idx = merged.findIndex((n) => n.label === item.afterLabel) const insertAt = idx >= 0 ? idx + 1 : merged.length merged.splice(insertAt, 0, { - label: item.label, to: item.to, icon: item.icon, exact: item.exact, + label: item.label, to: item.to, icon: item.icon, exact: item.exact, feature: item.feature, }) } return merged diff --git a/app/components/ChatbotQuotaUpsellCard.vue b/app/components/ChatbotQuotaUpsellCard.vue new file mode 100644 index 00000000..04d4aa81 --- /dev/null +++ b/app/components/ChatbotQuotaUpsellCard.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/components/FreePlanUpgradeMenu.vue b/app/components/FreePlanUpgradeMenu.vue index af612e19..dd1f2a99 100644 --- a/app/components/FreePlanUpgradeMenu.vue +++ b/app/components/FreePlanUpgradeMenu.vue @@ -8,7 +8,7 @@ * so the upsell and the org's current limits are reachable from every page, * not just buried in settings. */ -import { Briefcase, ArrowRight, MessageSquare, Sparkles, Zap } from 'lucide-vue-next' +import { Briefcase, ArrowRight, MessageCircle, MessageSquare, Sparkles, Zap } from 'lucide-vue-next' import { getBillingPlan } from '~~/shared/billing' import { freePlanUsage, useBillingStatus } from '~/composables/useBillingStatus' @@ -23,7 +23,8 @@ const atLimit = computed(() => { if (!u) return false const hit = (m: { used: number, limit: number | null }) => m.limit != null && Number.isFinite(m.limit) && m.used >= m.limit - return hit(u.activeRoles) || hit(u.aiAnalysis) || hit(u.candidateConversations) + return hit(u.activeRoles) || hit(u.aiAnalysis) || hit(u.aiAssistant) + || hit(u.candidateConversations) }) const recommended = getBillingPlan('solo')! @@ -75,6 +76,7 @@ watch(() => route.path, () => { open.value = false })
+
diff --git a/app/components/FreePlanUpsellCard.vue b/app/components/FreePlanUpsellCard.vue index d942e8c3..f6a09dc1 100644 --- a/app/components/FreePlanUpsellCard.vue +++ b/app/components/FreePlanUpsellCard.vue @@ -1,19 +1,21 @@