-
+
+ >
+
+
+
+
+
+
0')
+ .where('reporting_events.event_end_time <= ?', range.last)
+ .joins("INNER JOIN (#{resolved_events.to_sql}) resolves " \
+ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
+ 'AND reporting_events.event_end_time >= resolves.event_end_time')
+ .select('reporting_events.conversation_id')
+ conversations_for(ids)
+ end
+
+ # Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
+ def depth_conversations
+ conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
+ end
+
+ def conversations_for(conversation_ids)
+ account.conversations
+ .where(id: conversation_ids)
+ .includes(:assignee, :contact, :inbox)
+ .order(created_at: :desc)
+ end
+
+ def record_serializer(records)
+ @record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(account, metric, false, records)
+ end
+
+ def metric = params[:metric].to_s
+
+ def message_metric? = MESSAGE_METRICS.include?(metric)
+
+ def record_type = message_metric? ? 'message' : 'conversation'
+
+ def current_page = [params[:page].to_i, DEFAULT_PAGE].max
+
+ def per_page
+ requested_per_page = params[:per_page].to_i
+ requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
+
+ [requested_per_page, MAX_PER_PAGE].min
+ end
+end
diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb
new file mode 100644
index 000000000..d162406ad
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_stats_builder.rb
@@ -0,0 +1,219 @@
+# Computes per-assistant overview metrics for the Captain Overview page.
+# Each metric is returned for the current window and the previous equal-length
+# window, plus a derived trend.
+#
+# Queries are batched to cut round trips: the message-derived counts (handled,
+# public replies, depth) are computed for both windows in a single scan via
+# conditional FILTER aggregation.
+class Captain::AssistantStatsBuilder
+ RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
+ HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
+ BOT_RESOLVED_EVENT_NAME = 'conversation_bot_resolved'.freeze
+
+ # Assumed agent effort displaced by each public assistant reply. Reporting data
+ # only captures reply latency (customer wait time), not handling effort, so hours
+ # saved is a count-times-assumed-effort estimate rather than a measured duration.
+ SECONDS_SAVED_PER_REPLY = 2.minutes.to_i
+
+ attr_reader :assistant, :account
+
+ delegate :range, :period, to: :window
+
+ # `range` is either a day count ('7', '30', '90') or a named period
+ # ('this_month', 'last_month'). `timezone_offset` is the viewer's UTC offset in
+ # hours (as the reports API sends it), so month/day boundaries anchor to the
+ # viewer's day rather than UTC. Both windows are resolved by AssistantStatsWindow.
+ def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
+ @assistant = assistant
+ @account = assistant.account
+ @window = Captain::AssistantStatsWindow.new(range, timezone_offset)
+ end
+
+ def metrics
+ messages = message_window_metrics
+ current = window_metrics(current_range, messages[:current])
+ previous = window_metrics(previous_range, messages[:previous])
+
+ build_metrics(current, previous)
+ end
+
+ private
+
+ attr_reader :window
+
+ def current_range
+ window.current
+ end
+
+ def previous_range
+ window.previous
+ end
+
+ def build_metrics(current, previous)
+ {
+ conversations_handled: pack(current[:handled], previous[:handled], :percent),
+ auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
+ handoff_rate: pack(current[:handoff], previous[:handoff], :point),
+ hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
+ reopen_rate: pack(current[:reopen], previous[:reopen], :point),
+ conversation_depth: pack(current[:depth], previous[:depth], :absolute),
+ knowledge: knowledge
+ }
+ end
+
+ # Combines the per-window message counts with the reporting-event metrics for one window.
+ def window_metrics(range, message_counts)
+ handled = message_counts[:handled]
+ public_count = message_counts[:public_count]
+ depth_conversations = message_counts[:depth_conversations]
+ resolution = resolution_counts(range)
+
+ {
+ handled: handled,
+ auto_resolution: rate(resolution[:resolved], handled),
+ handoff: rate(resolution[:handoff], handled),
+ hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
+ reopen: reopen_rate(range),
+ depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
+ }
+ end
+
+ # One scan over the assistant's messages computes handled, public-reply count,
+ # and depth-conversation count for both windows via conditional aggregation.
+ def message_window_metrics
+ public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
+ cur = window_clause(current_range)
+ prev = window_clause(previous_range)
+
+ row = handled_scope(full_span).reorder(nil).pick(
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
+ Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
+ Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
+ )
+
+ {
+ current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
+ previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
+ }
+ end
+
+ # Resolved and handed-off conversation counts for one window, in a single scan
+ # of the handled set's reporting events.
+ def resolution_counts(range)
+ row = account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
+ created_at: range,
+ conversation_id: handled_scope(range).select(:conversation_id))
+ .reorder(nil)
+ .pick(
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{resolved_clause(range)})"),
+ Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
+ )
+ { resolved: row[0], handoff: row[1] }
+ end
+
+ # A countable resolve is any inference resolve, or a bot resolve on a conversation
+ # with no handoff in the window. conversation_bot_resolved fires on any resolve
+ # without an agent message (reporting_event_listener), so a handed-off conversation
+ # that goes quiet and gets closed would otherwise count as an auto-resolution too;
+ # the reports bot_resolutions metric applies the same exclusion (:exclude_bot_handoffs).
+ def resolved_clause(range)
+ "name IN (#{quoted(RESOLVED_EVENT_NAMES)}) AND #{bot_resolve_handoff_exclusion(range)}"
+ end
+
+ def bot_resolve_handoff_exclusion(range)
+ "NOT (name = #{quote(BOT_RESOLVED_EVENT_NAME)} AND conversation_id IN (#{handoff_conversation_ids(range).to_sql}))"
+ end
+
+ def handoff_conversation_ids(range)
+ account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
+ end
+
+ # Conversations the assistant participated in (authored any message).
+ def handled_scope(range)
+ account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
+ end
+
+ # Span covering both windows so a single scan can split them with FILTER.
+ def full_span
+ [current_range.first, previous_range.first].min..current_range.last
+ end
+
+ def window_clause(range)
+ "created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
+ end
+
+ def quote(value)
+ account.class.connection.quote(value)
+ end
+
+ def quoted(values)
+ values.map { |value| quote(value) }.join(', ')
+ end
+
+ # Of the conversations Captain auto-resolved, the share reopened afterwards. The cohort is
+ # derived from the assistant's handled conversations (not current inbox membership) so a later
+ # inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
+ # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
+ def reopen_rate(range)
+ resolved_scope = account.reporting_events
+ .where(name: RESOLVED_EVENT_NAMES, created_at: range,
+ conversation_id: handled_scope(range).select(:conversation_id))
+ .where(bot_resolve_handoff_exclusion(range))
+ # event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
+ # Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
+ # in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
+ # start time instead would misfire: the inference event is dispatched just after the generic
+ # conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
+ # The reopen itself must also fall inside the window, so a completed range (last_month, the
+ # previous window) doesn't count reopens that happened after it ended.
+ reopened = account.reporting_events
+ .where(name: 'conversation_opened')
+ .where('reporting_events.value > 0')
+ .where('reporting_events.event_end_time <= ?', range.last)
+ .joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
+ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
+ 'AND reporting_events.event_end_time >= resolves.event_end_time')
+ .distinct.count('reporting_events.conversation_id')
+ rate(reopened, resolved_scope.distinct.count(:conversation_id))
+ end
+
+ # Approved/pending FAQ counts and the document total in a single round trip.
+ def knowledge
+ approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
+ Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
+ Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
+ )
+ total = approved + pending
+
+ {
+ approved: approved,
+ pending: pending,
+ documents: documents,
+ coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
+ }
+ end
+
+ def rate(numerator, denominator)
+ return 0 if denominator.zero?
+
+ (numerator.to_f / denominator * 100).round(1)
+ end
+
+ def pack(current, previous, mode)
+ { current: current, previous: previous, trend: trend(current, previous, mode) }
+ end
+
+ def trend(current, previous, mode)
+ case mode
+ when :percent
+ previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
+ else # :point and :absolute are both current - previous
+ (current - previous).round(1)
+ end
+ end
+end
diff --git a/enterprise/app/builders/captain/assistant_stats_window.rb b/enterprise/app/builders/captain/assistant_stats_window.rb
new file mode 100644
index 000000000..4495ef1e8
--- /dev/null
+++ b/enterprise/app/builders/captain/assistant_stats_window.rb
@@ -0,0 +1,78 @@
+# Resolves the current and previous comparison windows for Captain assistant
+# stats. `range` is either a day count ('7', '30', '90') or a named period
+# ('this_month', 'last_month'). The previous window mirrors the current one: the
+# preceding N days for day ranges, or the preceding month for month ranges.
+# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
+# it), so month/day boundaries anchor to the viewer's day rather than UTC.
+#
+# Shared by Captain::AssistantStatsBuilder (which needs both windows) and
+# Captain::AssistantDrilldownBuilder (which drills into the current window), so a
+# drilldown always covers exactly the rows its stat card counted.
+class Captain::AssistantStatsWindow
+ include TimezoneHelper
+
+ DEFAULT_RANGE = '30'.freeze
+ ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
+
+ attr_reader :range
+
+ def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
+ @range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
+ @timezone = timezone_name_from_offset(timezone_offset) || Time.zone
+ end
+
+ def current
+ resolved_ranges[:current]
+ end
+
+ def previous
+ resolved_ranges[:previous]
+ end
+
+ # Human-readable description of the period the current window covers, for
+ # grounding the LLM summary in real dates.
+ def period
+ { label: period_label, starts_on: current.first.to_date, ends_on: current.last.to_date }
+ end
+
+ private
+
+ def resolved_ranges
+ @resolved_ranges ||= case range
+ when 'this_month' then this_month_ranges
+ when 'last_month' then last_month_ranges
+ else day_ranges
+ end
+ end
+
+ # Current time anchored to the viewer's timezone, so calendar boundaries land on
+ # the viewer's day instead of UTC's.
+ def now
+ @now ||= Time.current.in_time_zone(@timezone)
+ end
+
+ def this_month_ranges
+ start = now.beginning_of_month
+ elapsed = now - start
+ previous_start = start - 1.month
+ # Clamp to the previous month's end so a longer current month can't pull the
+ # comparison window into the current month and double-count its rows.
+ previous_end = [previous_start + elapsed, previous_start.end_of_month].min
+ { current: start..now, previous: previous_start..previous_end }
+ end
+
+ def last_month_ranges
+ start = (now - 1.month).beginning_of_month
+ previous_start = start - 1.month
+ { current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
+ end
+
+ def day_ranges
+ days = range.to_i
+ { current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
+ end
+
+ def period_label
+ { 'this_month' => 'this month', 'last_month' => 'last month' }[range] || "the last #{range.to_i} days"
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb
new file mode 100644
index 000000000..71772e4a0
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb
@@ -0,0 +1,7 @@
+class Api::V1::Accounts::CallsController < Api::V1::Accounts::EnterpriseAccountsController
+ def index
+ result = CallFinder.new(Current.user, Current.account, params).perform
+ @calls = result[:calls]
+ @calls_count = result[:count]
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index df9dfe5cc..4fbb93d20 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
- before_action :set_assistant, only: [:show, :update, :destroy, :playground]
+ before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -43,8 +43,53 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
+ def stats
+ render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
+ end
+
+ def summary
+ result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
+
+ if result[:error]
+ render json: { error: result[:error] }, status: :unprocessable_content
+ else
+ render json: { message: result[:message] }
+ end
+ end
+
+ def drilldown
+ return head :unprocessable_entity unless Captain::AssistantDrilldownBuilder.supported_metric?(params[:metric])
+
+ render json: Captain::AssistantDrilldownBuilder.new(@assistant, drilldown_params).build
+ end
+
private
+ def drilldown_params
+ params.permit(:metric, :range, :timezone_offset, :page, :per_page)
+ end
+
+ def cached_or_generated_summary(builder)
+ cache_key = summary_cache_key(builder.range)
+ cached = Rails.cache.read(cache_key)
+ return cached if cached
+
+ result = Captain::OverviewSummaryService.new(
+ account: Current.account,
+ assistant: @assistant,
+ first_name: Current.user.name.to_s.split.first,
+ stats: builder.metrics,
+ period: builder.period
+ ).perform
+ # Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
+ Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
+ result
+ end
+
+ def summary_cache_key(range)
+ "captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
+ end
+
def set_assistant
@assistant = account_assistants.find(params[:id])
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
index d176db597..621a508e4 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
@@ -2,13 +2,24 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
- before_action :check_cloud_env, only: [:limits, :toggle_deletion]
+ before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
def subscription
- if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
- @account.update(custom_attributes: { is_creating_customer: true })
- Enterprise::CreateStripeCustomerJob.perform_later(@account)
- end
+ return render json: currency_selection_payload if @account.billing_currency_selection_required?
+
+ ensure_stripe_customer
+ head :no_content
+ end
+
+ def select_billing_currency
+ return render_could_not_create_error(I18n.t('errors.billing.currency_locked')) if currency_locked?
+ return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless @account.billing_currency_selection_required?
+
+ currency = Enterprise::Billing::Currencies.normalize(params[:currency])
+ return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless Enterprise::Billing::Currencies.supported?(currency)
+
+ @account.update!(custom_attributes: @account.custom_attributes.merge('billing_currency' => currency))
+ ensure_stripe_customer
head :no_content
end
@@ -71,12 +82,32 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
render_could_not_create_error(e.message)
end
+ def topup_options
+ service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
+ render json: { id: @account.id, currency: @account.billing_currency, options: service.available_options }
+ end
+
private
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
+ def ensure_stripe_customer
+ return if stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
+
+ @account.update!(custom_attributes: @account.custom_attributes.merge('is_creating_customer' => true))
+ Enterprise::CreateStripeCustomerJob.perform_later(@account)
+ end
+
+ def currency_selection_payload
+ {
+ currency_selection_required: true,
+ currency_options: Enterprise::Billing::Currencies::SUPPORTED,
+ suggested_currency: Enterprise::Billing::Currencies.for_locale(@account.locale)
+ }
+ end
+
def default_limits
{
'conversation' => {},
@@ -98,6 +129,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id']
end
+ # Currency is fixed once a customer exists or creation is already in flight,
+ # so a second click can't bill a different currency than setup started with.
+ def currency_locked?
+ stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
+ end
+
def mark_for_deletion
reason = 'manual_deletion'
diff --git a/enterprise/app/fields/captain_model_overrides_field.rb b/enterprise/app/fields/captain_model_overrides_field.rb
index a8f3fe399..c361d511a 100644
--- a/enterprise/app/fields/captain_model_overrides_field.rb
+++ b/enterprise/app/fields/captain_model_overrides_field.rb
@@ -29,6 +29,8 @@ class CaptainModelOverridesField < Administrate::Field::Base
end
def default_model_id(feature_key)
+ return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && resource.feature_enabled?('captain_integration_v2')
+
Llm::Models.default_model_for(feature_key)
end
diff --git a/enterprise/app/finders/call_finder.rb b/enterprise/app/finders/call_finder.rb
new file mode 100644
index 000000000..31d6ae5f2
--- /dev/null
+++ b/enterprise/app/finders/call_finder.rb
@@ -0,0 +1,70 @@
+class CallFinder
+ RESULTS_PER_PAGE = 25
+
+ def initialize(current_user, current_account, params)
+ @current_user = current_user
+ @current_account = current_account
+ @params = params
+ end
+
+ def perform
+ @calls = @current_account.calls
+ filter_by_visibility
+ filter_by_status
+ filter_by_direction
+ filter_by_inbox
+ filter_by_agent
+ filter_by_date_range
+
+ { calls: paginated_calls, count: @calls.count }
+ end
+
+ private
+
+ # Admins and report managers see the whole account; everyone else only sees
+ # calls they handled within conversations they can still access.
+ def filter_by_visibility
+ return if account_wide_access?
+
+ @calls = @calls.where(accepted_by_agent_id: @current_user.id, conversation_id: accessible_conversations)
+ end
+
+ def accessible_conversations
+ Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform.select(:id)
+ end
+
+ def account_wide_access?
+ account_user = Current.account_user
+ account_user&.administrator? || account_user&.custom_role&.permissions&.include?('report_manage')
+ end
+
+ def filter_by_status
+ @calls = @calls.where(status: Call.status_from_display(@params[:status])) if @params[:status].present?
+ end
+
+ def filter_by_direction
+ @calls = @calls.where(direction: Call.direction_from_label(@params[:direction])) if @params[:direction].present?
+ end
+
+ def filter_by_inbox
+ @calls = @calls.where(inbox_id: @params[:inbox_id]) if @params[:inbox_id].present?
+ end
+
+ def filter_by_agent
+ @calls = @calls.where(accepted_by_agent_id: @params[:agent_id]) if @params[:agent_id].present?
+ end
+
+ # since/until are unix timestamps, matching DateRangeHelper conventions.
+ def filter_by_date_range
+ return if @params[:since].blank? || @params[:until].blank?
+
+ @calls = @calls.where(created_at: Time.zone.at(@params[:since].to_i)..Time.zone.at(@params[:until].to_i))
+ end
+
+ def paginated_calls
+ @calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
+ .order(created_at: :desc)
+ .page(@params[:page] || 1)
+ .per(RESULTS_PER_PAGE)
+ end
+end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index 71dfac100..8c76103ea 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -78,6 +78,17 @@ class Call < ApplicationRecord
DISPLAY_DIRECTION[direction]
end
+ # Normalize filter values back to stored forms so API/dashboard clients can
+ # query using either the display value (inbound/outbound, in-progress) or the
+ # stored value (incoming/outgoing, in_progress).
+ def self.direction_from_label(value)
+ DISPLAY_DIRECTION.key(value) || value
+ end
+
+ def self.status_from_display(value)
+ value.to_s.tr('-', '_')
+ end
+
def ringing?
status == 'ringing'
end
diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb
index b5a6cd1b9..086deedc1 100644
--- a/enterprise/app/models/concerns/agentable.rb
+++ b/enterprise/app/models/concerns/agentable.rb
@@ -47,7 +47,7 @@ module Concerns::Agentable
def agent_model
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
- return route[:model] if route[:source] == :account_override
+ return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
installation_model.presence || route[:model]
end
diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb
index 9b451a23c..62898803a 100644
--- a/enterprise/app/models/enterprise/account.rb
+++ b/enterprise/app/models/enterprise/account.rb
@@ -1,4 +1,9 @@
module Enterprise::Account
+ # Transitional marker for the Captain V1 to V2 rollout. New cloud accounts get
+ # this marker so plan reconciliation can enable V2 for them without upgrading
+ # existing paid accounts. Remove once every account is migrated to V2.
+ CAPTAIN_V2_DEFAULT_ELIGIBLE = 'captain_v2_default_eligible'.freeze
+
class << self
def captain_document_sync_intervals
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
@@ -68,8 +73,41 @@ module Enterprise::Account
saml_settings&.saml_enabled? || false
end
+ def billing_currency
+ # Feature off => everyone is billed in USD (legacy behaviour).
+ return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
+
+ stored = custom_attributes&.dig('billing_currency')
+ return Enterprise::Billing::Currencies.normalize(stored) if Enterprise::Billing::Currencies.supported?(stored)
+
+ # Existing Stripe customers stay on USD (webhook backfills the real currency);
+ # only brand-new accounts infer from locale, so existing pt_BR users aren't charged BRL.
+ return Enterprise::Billing::Currencies::DEFAULT if custom_attributes&.dig('stripe_customer_id').present?
+
+ Enterprise::Billing::Currencies.for_locale(locale)
+ end
+
+ # New accounts whose locale maps to a non-USD currency get to pick USD or that
+ # currency before the Stripe customer is created; everyone else proceeds in USD.
+ def billing_currency_selection_required?
+ return false unless Enterprise::Billing::Currencies.enabled?
+ return false if custom_attributes&.dig('stripe_customer_id').present?
+ return false if Enterprise::Billing::Currencies.supported?(custom_attributes&.dig('billing_currency'))
+
+ Enterprise::Billing::Currencies.for_locale(locale) != Enterprise::Billing::Currencies::DEFAULT
+ end
+
private
+ def enable_default_features
+ super
+ if ChatwootApp.self_hosted_enterprise?
+ enable_features('captain_integration', 'captain_integration_v2')
+ elsif ChatwootApp.chatwoot_cloud?
+ internal_attributes[CAPTAIN_V2_DEFAULT_ELIGIBLE] = true
+ end
+ end
+
def sync_assignment_features
if feature_enabled?('assignment_v2')
# Enable advanced_assignment for Business/Enterprise plans
diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb
index bdcd0fd63..b327878b4 100644
--- a/enterprise/app/models/enterprise/concerns/inbox.rb
+++ b/enterprise/app/models/enterprise/concerns/inbox.rb
@@ -8,5 +8,11 @@ module Enterprise::Concerns::Inbox
class_name: 'Captain::Assistant'
has_many :inbox_capacity_limits, dependent: :destroy
has_many :calls, dependent: :destroy_async
+
+ before_create :ensure_create_permitted
+ end
+
+ def ensure_create_permitted
+ raise CustomExceptions::Inbox::LimitExceeded.new({}) if account.inboxes.count >= account.usage_limits[:inboxes]
end
end
diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb
index bbde3ffb0..573c0400c 100644
--- a/enterprise/app/policies/captain/assistant_policy.rb
+++ b/enterprise/app/policies/captain/assistant_policy.rb
@@ -11,6 +11,14 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
+ def summary?
+ true
+ end
+
+ def drilldown?
+ @account_user.administrator?
+ end
+
def tools?
@account_user.administrator?
end
diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
index 79e5ee258..7a2e587fd 100644
--- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
+++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
@@ -1,4 +1,6 @@
class Enterprise::Billing::CreateStripeCustomerService
+ include BillingHelper
+
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
@@ -21,13 +23,22 @@ class Enterprise::Billing::CreateStripeCustomerService
def prepare_customer_id
customer_id = account.custom_attributes['stripe_customer_id']
- if customer_id.blank?
- customer = Stripe::Customer.create({ name: account.name, email: billing_email })
- customer_id = customer.id
- end
+ customer_id = Stripe::Customer.create(customer_params).id if customer_id.blank?
customer_id
end
+ # Only currencies that need a country override (e.g. BRL/PIX) set address/locale; usd keeps Stripe defaults.
+ def customer_params
+ params = { name: account.name, email: billing_email }
+ country = Enterprise::Billing::Currencies.country_for(account.billing_currency)
+ return params if country.blank?
+
+ params.merge(
+ address: { country: country },
+ preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
+ )
+ end
+
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
end
@@ -37,13 +48,11 @@ class Enterprise::Billing::CreateStripeCustomerService
end
def default_plan
- installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
- @default_plan ||= installation_config.value.first
+ @default_plan ||= Enterprise::Billing::PlanConfiguration.default_plan
end
def price_id
- price_ids = default_plan['price_ids']
- price_ids.first
+ Enterprise::Billing::PlanConfiguration.price_id_for(default_plan, account.billing_currency)
end
def active_subscription
@@ -60,7 +69,7 @@ class Enterprise::Billing::CreateStripeCustomerService
end
def default_plan_subscription?(subscription)
- default_plan['price_ids'].include?(subscription['plan']['id'])
+ Enterprise::Billing::PlanConfiguration.plan_contains_product_id?(default_plan, subscription['plan']['product'])
end
def build_custom_attributes(customer_id, subscription)
@@ -71,14 +80,14 @@ class Enterprise::Billing::CreateStripeCustomerService
'plan_name' => default_plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
- 'subscription_ends_on' => subscription_ends_on(subscription)
+ 'subscription_ends_on' => subscription_ends_on(subscription),
+ 'billing_currency' => billing_currency_for(subscription)
)
end
- def subscription_ends_on(subscription)
- period_end = subscription['current_period_end']
- return if period_end.blank?
-
- Time.zone.at(period_end)
+ # Persist the currency Stripe actually billed, read straight from the price; the
+ # requested currency may lack a configured price and fall back to usd.
+ def billing_currency_for(subscription)
+ Enterprise::Billing::Currencies.to_supported(subscription['plan']['currency'])
end
end
diff --git a/enterprise/app/services/enterprise/billing/currencies.rb b/enterprise/app/services/enterprise/billing/currencies.rb
new file mode 100644
index 000000000..46fb9ecd9
--- /dev/null
+++ b/enterprise/app/services/enterprise/billing/currencies.rb
@@ -0,0 +1,55 @@
+# Supported billing currencies and their Stripe/locale mappings.
+module Enterprise::Billing::Currencies
+ DEFAULT = 'usd'.freeze
+
+ SUPPORTED = %w[usd brl].freeze
+
+ FEATURE_CONFIG = 'ENABLE_MULTI_CURRENCY_BILLING'.freeze
+
+ # Account locale label (e.g. 'pt_BR') => default currency; unlisted falls back to DEFAULT.
+ LOCALE_DEFAULTS = {
+ 'pt_BR' => 'brl'
+ }.freeze
+
+ # Billing country override per currency; absent currencies (e.g. usd) keep Stripe's default.
+ COUNTRY_BY_CURRENCY = {
+ 'brl' => 'BR'
+ }.freeze
+
+ # Preferred Stripe/checkout locale per currency; absent currencies keep Stripe's default.
+ PREFERRED_LOCALE_BY_CURRENCY = {
+ 'brl' => 'pt-BR'
+ }.freeze
+
+ module_function
+
+ # Master switch for the whole multi-currency feature; off => everyone is billed in USD.
+ def enabled?
+ GlobalConfigService.load(FEATURE_CONFIG, 'false').to_s != 'false'
+ end
+
+ def normalize(code)
+ code.to_s.strip.downcase.presence
+ end
+
+ def supported?(code)
+ SUPPORTED.include?(normalize(code))
+ end
+
+ # Map arbitrary input to a supported code, else DEFAULT.
+ def to_supported(code)
+ supported?(code) ? normalize(code) : DEFAULT
+ end
+
+ def for_locale(locale)
+ LOCALE_DEFAULTS.fetch(locale.to_s, DEFAULT)
+ end
+
+ def country_for(code)
+ COUNTRY_BY_CURRENCY[to_supported(code)]
+ end
+
+ def preferred_locale_for(code)
+ PREFERRED_LOCALE_BY_CURRENCY[to_supported(code)]
+ end
+end
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index 8342343e9..add6dbd08 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -1,4 +1,6 @@
class Enterprise::Billing::HandleStripeEventService
+ include BillingHelper
+
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
@@ -65,11 +67,19 @@ class Enterprise::Billing::HandleStripeEventService
'plan_name' => plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
- 'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
+ 'subscription_ends_on' => subscription_ends_on(subscription),
+ 'billing_currency' => billing_currency_for(subscription, plan)
)
)
end
+ # Paid subscriptions define the currency; the free/default plan keeps the stored preference.
+ def billing_currency_for(subscription, plan)
+ return account.billing_currency if plan['name'] == Enterprise::Billing::PlanConfiguration.default_plan&.dig('name')
+
+ Enterprise::Billing::Currencies.to_supported(subscription['plan']['currency'])
+ end
+
def track_marketing_plan_activation(previous_plan_name, current_plan_name)
subscription_plan = subscription['plan']
@@ -161,8 +171,8 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
- def find_plan(plan_id)
- cloud_plans.find { |config| config['product_id'].include?(plan_id) }
+ def find_plan(product_id)
+ Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(product_id)
end
def previous_plan_name
@@ -171,8 +181,4 @@ class Enterprise::Billing::HandleStripeEventService
find_plan(stripe_plan['product'])&.dig('name')
end
-
- def cloud_plans
- @cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
- end
end
diff --git a/enterprise/app/services/enterprise/billing/plan_configuration.rb b/enterprise/app/services/enterprise/billing/plan_configuration.rb
new file mode 100644
index 000000000..25683cbc0
--- /dev/null
+++ b/enterprise/app/services/enterprise/billing/plan_configuration.rb
@@ -0,0 +1,46 @@
+# Resolves Stripe price ids from CHATWOOT_CLOUD_PLANS per currency.
+# A plan's `price_ids` may be a currency-keyed Hash, or a legacy Array (treated as usd).
+module Enterprise::Billing::PlanConfiguration
+ CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
+
+ module_function
+
+ def plans
+ InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
+ end
+
+ def default_plan
+ plans.first
+ end
+
+ # Handles both shapes during migration; once all configs are currency-keyed Hashes, drop the Array branch.
+ def price_ids_by_currency(plan)
+ raw = plan && plan['price_ids']
+ case raw
+ when Hash then raw.transform_keys { |key| Enterprise::Billing::Currencies.normalize(key) }
+ when Array then { Enterprise::Billing::Currencies::DEFAULT => raw }
+ else {}
+ end
+ end
+
+ # Price id for `plan` in `currency`, falling back to usd then any configured price.
+ # The multi-step fallback is migration-era safety; once configs settle on one format we can simplify this.
+ def price_id_for(plan, currency)
+ by_currency = price_ids_by_currency(plan)
+ code = Enterprise::Billing::Currencies.to_supported(currency)
+
+ (by_currency[code].presence ||
+ by_currency[Enterprise::Billing::Currencies::DEFAULT].presence ||
+ by_currency.values.flatten.compact).first
+ end
+
+ # Match by product id, not price id: production has prices that aren't enumerated
+ # in our config but share a product, so product matching still resolves the plan.
+ def plan_contains_product_id?(plan, product_id)
+ Array(plan && plan['product_id']).include?(product_id)
+ end
+
+ def find_plan_by_product_id(product_id)
+ plans.find { |plan| plan_contains_product_id?(plan, product_id) }
+ end
+end
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
index fea5b7664..205bc348e 100644
--- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -36,7 +36,9 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
+ account.disable_features('captain_integration_v2') if default_plan?
account.enable_features(*current_plan_features)
+ account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
account.enable_features(*manually_managed_features)
account.save!
end
@@ -69,4 +71,8 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
+
+ def captain_v2_default_eligible?
+ !default_plan? && account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE] == true
+ end
end
diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
index 00e2b1646..58eac1b4d 100644
--- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
+++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
@@ -3,15 +3,15 @@ class Enterprise::Billing::TopupCheckoutService
class Error < StandardError; end
- TOPUP_OPTIONS = [
- { credits: 1000, amount: 20.0, currency: 'usd' },
- { credits: 2500, amount: 50.0, currency: 'usd' },
- { credits: 6000, amount: 100.0, currency: 'usd' },
- { credits: 12_000, amount: 200.0, currency: 'usd' }
- ].freeze
+ TOPUP_OPTIONS_CONFIG = 'CAPTAIN_TOPUP_OPTIONS'.freeze
pattr_initialize [:account!]
+ # Topup packages for the account's billing currency (used by the controller).
+ def available_options
+ topup_options
+ end
+
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
charge_customer(topup_option, credits)
@@ -100,6 +100,20 @@ class Enterprise::Billing::TopupCheckoutService
end
def find_topup_option(credits)
- TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
+ topup_options.find { |opt| opt[:credits] == credits.to_i }
+ end
+
+ def topup_options
+ # Label rows with the currency they were configured under, so a DEFAULT fallback can't relabel USD amounts and undercharge.
+ options = configured_options
+ currency = options[account.billing_currency].present? ? account.billing_currency : Enterprise::Billing::Currencies::DEFAULT
+ rows = options[currency].presence || []
+ rows.map { |opt| { credits: opt['credits'].to_i, amount: opt['amount'].to_f, currency: currency } }
+ end
+
+ def configured_options
+ config = InstallationConfig.find_by(name: TOPUP_OPTIONS_CONFIG)&.value
+ config = JSON.parse(config) if config.is_a?(String)
+ config || {}
end
end
diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb
index bec3b5cb9..a84060775 100644
--- a/enterprise/app/services/llm/base_ai_service.rb
+++ b/enterprise/app/services/llm/base_ai_service.rb
@@ -34,7 +34,7 @@ class Llm::BaseAiService
def setup_model
route = feature_route
- return @model = route[:model] if account_override_route?(route)
+ return @model = route[:model] if account_override_route?(route) || captain_v2_assistant?
@model = @fallback_model.presence || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL
end
@@ -49,6 +49,10 @@ class Llm::BaseAiService
route&.dig(:source) == :account_override
end
+ def captain_v2_assistant?
+ @llm_feature.to_s == 'assistant' && @llm_account&.feature_enabled?('captain_integration_v2')
+ end
+
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
end
diff --git a/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
new file mode 100644
index 000000000..15f66ddc1
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder
@@ -0,0 +1,11 @@
+json.meta do
+ json.count @calls_count
+ json.current_page @calls.current_page
+ json.total_pages @calls.total_pages
+end
+
+json.payload do
+ json.array! @calls do |call|
+ json.partial! 'api/v1/models/call', formats: [:json], call: call
+ end
+end
diff --git a/enterprise/app/views/api/v1/models/_call.json.jbuilder b/enterprise/app/views/api/v1/models/_call.json.jbuilder
new file mode 100644
index 000000000..7a3531b39
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/_call.json.jbuilder
@@ -0,0 +1,40 @@
+json.id call.id
+json.call_id call.provider_call_id
+json.provider call.provider
+json.status call.display_status
+json.direction call.direction_label
+json.duration_seconds call.duration_seconds
+json.end_reason call.end_reason
+json.started_at call.started_at&.to_i
+json.created_at call.created_at.to_i
+json.message_id call.message_id
+json.recording_url call.recording_url
+json.transcript call.transcript
+
+json.conversation do
+ json.id call.conversation_id
+ json.display_id call.conversation.display_id
+end
+
+json.inbox do
+ json.id call.inbox_id
+ json.name call.inbox.name
+end
+
+if call.accepted_by_agent
+ json.agent do
+ json.id call.accepted_by_agent.id
+ json.name call.accepted_by_agent.available_name
+ json.avatar call.accepted_by_agent.avatar_url
+ end
+else
+ json.agent nil
+end
+
+contact = call.contact
+json.contact do
+ json.id contact.id
+ json.name contact.name
+ json.phone_number contact.phone_number
+ json.avatar contact.avatar_url
+end
diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml
index 282319fe7..260de1356 100644
--- a/enterprise/config/premium_features.yml
+++ b/enterprise/config/premium_features.yml
@@ -4,6 +4,7 @@
- sla
- custom_roles
- captain_integration
+- captain_integration_v2
- captain_document_auto_sync
- csat_review_notes
- conversation_required_attributes
diff --git a/lib/captain/overview_summary_service.rb b/lib/captain/overview_summary_service.rb
new file mode 100644
index 000000000..e11132d42
--- /dev/null
+++ b/lib/captain/overview_summary_service.rb
@@ -0,0 +1,83 @@
+# Generates the LLM welcome summary for the Captain Overview page from the
+# assistant's stats hash (see Captain::AssistantStatsBuilder). Renders the
+# captain_overview_summary.liquid prompt and returns markdown.
+class Captain::OverviewSummaryService < Captain::BaseTaskService
+ pattr_initialize [:account!, :assistant!, :first_name!, :stats!, :period!]
+
+ def perform
+ api_response = make_api_call(
+ feature: 'editor',
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: 'Write the summary.' }
+ ]
+ )
+
+ return api_response if api_response[:error]
+
+ { message: api_response[:message] }
+ end
+
+ private
+
+ def system_prompt
+ Liquid::Template.parse(prompt_from_file('captain_overview_summary')).render(prompt_variables)
+ end
+
+ def prompt_variables
+ stat_variables.merge(period_variables)
+ end
+
+ def stat_variables
+ {
+ 'first_name' => first_name.to_s,
+ 'assistant_name' => assistant.name.to_s,
+ 'conversations_handled' => current(:conversations_handled),
+ 'hours_saved' => current(:hours_saved),
+ 'auto_resolution_rate' => current(:auto_resolution_rate),
+ 'auto_resolution_trend' => trend(:auto_resolution_rate),
+ 'handoff_rate' => current(:handoff_rate),
+ 'handoff_trend' => trend(:handoff_rate),
+ 'reopen_rate' => current(:reopen_rate),
+ 'reopen_trend' => trend(:reopen_rate),
+ 'knowledge_coverage' => stats.dig(:knowledge, :coverage).to_s,
+ 'knowledge_approved' => stats.dig(:knowledge, :approved).to_s,
+ 'knowledge_documents' => stats.dig(:knowledge, :documents).to_s
+ }
+ end
+
+ def period_variables
+ {
+ 'today' => formatted_date(Time.zone.today),
+ 'period_label' => period[:label].to_s,
+ 'period_start' => formatted_date(period[:starts_on]),
+ 'period_end' => formatted_date(period[:ends_on])
+ }
+ end
+
+ def formatted_date(date)
+ date.strftime('%B %-d, %Y')
+ end
+
+ def current(key)
+ stats.dig(key, :current).to_s
+ end
+
+ def trend(key)
+ stats.dig(key, :trend).to_s
+ end
+
+ def event_name
+ 'captain_overview_summary'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+
+ # The overview summary is an internal analytics readout, not a customer-facing
+ # response, so it should not consume or be blocked by the captain_responses quota.
+ def counts_toward_usage?
+ false
+ end
+end
diff --git a/lib/custom_exceptions/inbox/limit_exceeded.rb b/lib/custom_exceptions/inbox/limit_exceeded.rb
new file mode 100644
index 000000000..9b5624929
--- /dev/null
+++ b/lib/custom_exceptions/inbox/limit_exceeded.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CustomExceptions::Inbox::LimitExceeded < CustomExceptions::Base
+ def message
+ 'Account limit exceeded. Upgrade to a higher plan'
+ end
+
+ def to_hash
+ { error: message }
+ end
+
+ def http_status
+ :payment_required
+ end
+end
diff --git a/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
new file mode 100644
index 000000000..19c32ed7c
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/captain_overview_summary.liquid
@@ -0,0 +1,38 @@
+You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it.
+
+Voice and format:
+- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic.
+- Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant".
+- This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in".
+- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up.
+- Output plain markdown only: no headings, lists, preamble, or sign-off. Do not use em dashes.
+- Never state an exact figure. This summary is cached and the live numbers keep moving, so a precise value would quickly look wrong. Round every number down to a clean approximation and soften it with words like "around", "roughly", "about", "nearly", "just over", or "upwards of". For example, render **1,248** as "upwards of **1,200**", **63.2%** as "around **60%**", and **612** hours as "roughly **600** hours". For a small count, use a loose phrase like "a handful" instead of the exact number.
+- Wrap the approximate figure in **double asterisks** so the interface can highlight it. Bold only the figures, never whole phrases or the softening word.
+
+Timing: today is {{ today }}. These stats cover {{ period_label }} ({{ period_start }} to {{ period_end }}). You may lightly reference the month, the season, or how far into the period things stand when it genuinely fits, but never invent events or facts.
+
+The stats for this period:
+
+- Conversations handled: {{ conversations_handled }}. Distinct conversations {{ assistant_name }} replied in at least once. Raw volume and adoption, not a measure of quality.
+- Hours saved: {{ hours_saved }} hours. A rough, directional estimate of agent time saved. A feel-good figure, not exact measured labor.
+- Auto-resolution rate: {{ auto_resolution_rate }}% ({{ auto_resolution_trend }} points vs previous period). Of the conversations it handled, the share {{ assistant_name }} resolved on its own with no human reply. The core performance signal; higher is better.
+- Handoff rate: {{ handoff_rate }}% ({{ handoff_trend }} points vs previous period). Of the conversations it handled, the share it escalated to a human agent. The inverse of deflection; lower is better.
+- Reopen-after-resolve rate: {{ reopen_rate }}% ({{ reopen_trend }} points vs previous period). Of the conversations it auto-resolved, the share later reopened. A quality signal; lower is better, and a high value means it closed conversations the customer was not actually done with.
+- Knowledge base: {{ knowledge_approved }} approved FAQ answers, {{ knowledge_documents }} documents, {{ knowledge_coverage }}% coverage (the share of FAQ answers the team has approved). This is setup the team controls, not something {{ assistant_name }} earned. It is a leading indicator: low coverage tends to cause low auto-resolution.
+
+
+Only the auto-resolution, handoff, and reopen rates reflect how {{ assistant_name }} actually performed, and they are the only things worth crediting it for. Conversations handled and hours saved are context. The knowledge base is an input, never a win to praise.
+
+How to judge the numbers (rough bands, do not quote them in the summary):
+- Auto-resolution rate: below 30% is low and early-stage, 30 to 50% is decent, above 50% is genuinely strong.
+- Handoff rate: above 60% is high, 30 to 60% is moderate, below 30% is strong.
+- Reopen-after-resolve rate: below 5% is healthy, 5 to 15% is worth watching, above 15% is a real problem.
+- Knowledge coverage: only worth mentioning when below 85% (below 60% is seriously thin), as a likely cause of weak auto-resolution. At 85% or above it is just the healthy baseline, so do not mention or praise it.
+- When the conversation volume is small (roughly under 30), rates are noisy, so describe them tentatively and do not over-interpret a perfect or terrible looking percentage.
+
+Writing the summary:
+- Cold start: if conversations handled is 0, there is no performance to report. Skip the auto-resolution, handoff, reopen, and hours-saved figures entirely. Instead note the knowledge base and say {{ assistant_name }} is set up and ready to start handling support (or ready to start once some knowledge is added, if the base is empty). Ignore the rest of these points in this case.
+- Be honest and proportionate. Do not call a result impressive, strong, excellent, solid, flawless, or perfect unless it clears the "strong" band above. State a low or middling number plainly or as room to grow, never dressed up. A modest summary is fine and often correct.
+- Lead with the genuinely strong results if there are any. If nothing clears the strong band, open plainly with the volume of work handled, without overselling it.
+- Mention a trend only when it is meaningful, and judge it against the bands rather than the direction alone (a rate that rose but is still in the low band is not yet a win).
+- Surface at most one proactive concern when a stat warrants it (a high handoff rate, a low auto-resolution rate, a rising reopen rate, or thin coverage). Skip it entirely when everything looks healthy. Keep it a calm observation about the data, not an alarm.
diff --git a/lib/llm/feature_router.rb b/lib/llm/feature_router.rb
index da0aa56e9..d75b06dca 100644
--- a/lib/llm/feature_router.rb
+++ b/lib/llm/feature_router.rb
@@ -1,6 +1,8 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
+ CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
+
class << self
def resolve(feature:, account: nil)
feature_key = feature.to_s
@@ -8,6 +10,7 @@ module Llm::FeatureRouter
model = account_model_override(account, feature_key)
source = model.present? ? :account_override : :default
+ model ||= captain_v2_assistant_model(account, feature_key)
model ||= Llm::Models.default_model_for(feature_key)
{
@@ -25,5 +28,12 @@ module Llm::FeatureRouter
return unless model
return model if Llm::Models.valid_model_for?(feature_key, model)
end
+
+ def captain_v2_assistant_model(account, feature_key)
+ return unless feature_key == 'assistant'
+ return unless account&.feature_enabled?('captain_integration_v2')
+
+ CAPTAIN_V2_ASSISTANT_MODEL
+ end
end
end
diff --git a/lib/seeders/reports/assistant_conversation_creator.rb b/lib/seeders/reports/assistant_conversation_creator.rb
new file mode 100644
index 000000000..9462d899e
--- /dev/null
+++ b/lib/seeders/reports/assistant_conversation_creator.rb
@@ -0,0 +1,203 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+# Seeds Captain assistant activity for the reports/overview test data.
+#
+# Produces a variety of assistant-handled conversations in a single web inbox so
+# every Captain assistant overview metric (handled, auto-resolution, handoff,
+# hours saved, reopen rate, conversation depth) has realistic data:
+# - :resolved_by_assistant assistant answers and Captain auto-resolves
+# - :handled_by_both assistant answers, a human also replies and resolves
+# - :handed_off assistant answers, then hands off to a human
+# - :resolved_and_reopened Captain resolves, then the conversation reopens
+#
+# Reporting events are fired through ReportingEventListener directly (mirroring
+# ConversationCreator) so the same rows the builder reads from get populated.
+class Seeders::Reports::AssistantConversationCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ OUTCOMES = %i[resolved_by_assistant handled_by_both handed_off resolved_and_reopened].freeze
+
+ def initialize(account:, assistant:, inbox:, resources:)
+ @account = account
+ @assistant = assistant
+ @inbox = inbox
+ @contacts = resources[:contacts]
+ @agents = inbox.members.to_a.presence || resources[:agents]
+ end
+
+ def create_conversation(created_at:, outcome:)
+ conversation = nil
+
+ travel_to(created_at) do
+ conversation = build_conversation
+ conversation.save!
+ seed_dialogue(conversation, outcome)
+ end
+ travel_back
+
+ apply_outcome(conversation, created_at, outcome)
+ conversation
+ end
+
+ private
+
+ def build_conversation
+ contact = @contacts.sample
+ contact_inbox = @inbox.contact_inboxes.find_or_create_by!(contact: contact, source_id: SecureRandom.hex)
+
+ contact_inbox.conversations.create!(
+ account: @account,
+ inbox: @inbox,
+ contact: contact,
+ priority: [nil, 'high', 'medium', 'low'].sample
+ )
+ end
+
+ # Builds the message exchange for the conversation while time is frozen at its
+ # creation moment. Every outcome starts with a customer question and at least
+ # one public assistant reply so the conversation lands in the assistant's
+ # handled set; some outcomes add a human reply or a handoff.
+ def seed_dialogue(conversation, outcome)
+ customer_message = incoming_message(conversation)
+
+ travel(rand((20.seconds)..(5.minutes)))
+ assistant_reply(conversation, waiting_since: customer_message.created_at)
+
+ case outcome
+ when :handed_off then seed_handoff(conversation)
+ when :handled_by_both then seed_human_turn(conversation)
+ else seed_assistant_follow_up(conversation)
+ end
+ end
+
+ def seed_handoff(conversation)
+ travel(rand((1.minute)..(10.minutes)))
+ handoff_to_human(conversation)
+ travel(rand((1.minute)..(15.minutes)))
+ human_reply(conversation)
+ end
+
+ def seed_human_turn(conversation)
+ travel(rand((1.minute)..(15.minutes)))
+ human_reply(conversation)
+ end
+
+ # Pure assistant threads occasionally take a second turn, giving depth > 1.
+ def seed_assistant_follow_up(conversation)
+ return unless rand < 0.6
+
+ travel(rand((1.minute)..(10.minutes)))
+ follow_up = incoming_message(conversation)
+ travel(rand((20.seconds)..(5.minutes)))
+ assistant_reply(conversation, waiting_since: follow_up.created_at)
+ end
+
+ def apply_outcome(conversation, created_at, outcome)
+ resolved_at = created_at + rand((30.minutes)..(8.hours))
+
+ case outcome
+ when :resolved_by_assistant
+ resolve_by_captain(conversation, resolved_at)
+ when :handled_by_both
+ resolve_by_human(conversation, resolved_at)
+ when :handed_off
+ resolve_by_human(conversation, resolved_at) if rand < 0.6
+ when :resolved_and_reopened
+ resolve_by_captain(conversation, resolved_at)
+ reopen(conversation, resolved_at + rand((1.hour)..(24.hours)))
+ end
+ end
+
+ def incoming_message(conversation)
+ conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :incoming,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..3)),
+ sender: conversation.contact
+ )
+ end
+
+ def assistant_reply(conversation, waiting_since:)
+ message = conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :outgoing,
+ private: false,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
+ sender: @assistant
+ )
+ trigger_reply_time(message, waiting_since)
+ message
+ end
+
+ def human_reply(conversation)
+ agent = @agents.sample
+ conversation.update_column(:assignee_id, agent.id) if conversation.assignee_id.nil? # rubocop:disable Rails/SkipsModelValidations
+
+ conversation.messages.create!(
+ account: @account,
+ inbox: @inbox,
+ message_type: :outgoing,
+ private: false,
+ content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
+ sender: agent
+ )
+ end
+
+ def resolve_by_captain(conversation, resolved_at)
+ mark_resolved(conversation, resolved_at)
+ travel_to(resolved_at) do
+ trigger_event('conversation_resolved', conversation)
+ trigger_event('conversation_captain_inference_resolved', conversation)
+ end
+ travel_back
+ end
+
+ def resolve_by_human(conversation, resolved_at)
+ mark_resolved(conversation, resolved_at)
+ travel_to(resolved_at) do
+ trigger_event('conversation_resolved', conversation)
+ end
+ travel_back
+ end
+
+ def reopen(conversation, reopened_at)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :open)
+ conversation.update_column(:updated_at, reopened_at)
+ # rubocop:enable Rails/SkipsModelValidations
+
+ travel_to(reopened_at) do
+ trigger_event('conversation_opened', conversation)
+ end
+ travel_back
+ end
+
+ def handoff_to_human(conversation)
+ trigger_event('conversation_captain_inference_handoff', conversation)
+ end
+
+ def mark_resolved(conversation, resolved_at)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:status, :resolved)
+ conversation.update_column(:updated_at, resolved_at)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ def trigger_event(name, conversation)
+ ReportingEventListener.instance.public_send(
+ name, Events::Base.new(name, Time.current, { conversation: conversation })
+ )
+ end
+
+ def trigger_reply_time(message, waiting_since)
+ ReportingEventListener.instance.reply_created(
+ Events::Base.new('reply_created', Time.current,
+ { message: message, conversation: message.conversation, waiting_since: waiting_since })
+ )
+ end
+end
diff --git a/lib/seeders/reports/report_data_seeder.rb b/lib/seeders/reports/report_data_seeder.rb
index 909818b72..fecb02deb 100644
--- a/lib/seeders/reports/report_data_seeder.rb
+++ b/lib/seeders/reports/report_data_seeder.rb
@@ -17,6 +17,9 @@
# - 5 teams with realistic distribution
# - 30 labels with random assignments
# - 3 inboxes with agent assignments
+# - 1 Captain assistant bound to a single web inbox, with knowledge (FAQs + documents)
+# and a variety of assistant-handled conversations (auto-resolved, handed off,
+# handled with a human, resolved-then-reopened) for the assistant overview page
# - Realistic reporting events with historical timestamps
#
# Note: This seeder clears existing data for the account before seeding.
@@ -24,8 +27,9 @@
require 'faker'
require_relative 'conversation_creator'
require_relative 'message_creator'
+require_relative 'assistant_conversation_creator'
-# rubocop:disable Rails/Output
+# rubocop:disable Rails/Output, Metrics/ClassLength
class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers
@@ -36,6 +40,11 @@ class Seeders::Reports::ReportDataSeeder
TOTAL_LABELS = 30
TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
+ # Captain assistant conversations, split across the outcomes the overview page reports on.
+ TOTAL_ASSISTANT_CONVERSATIONS = 120
+ ASSISTANT_KNOWLEDGE_APPROVED = 14
+ ASSISTANT_KNOWLEDGE_PENDING = 6
+ ASSISTANT_DOCUMENTS = 4
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
@@ -48,6 +57,8 @@ class Seeders::Reports::ReportDataSeeder
@labels = []
@inboxes = []
@contacts = []
+ @assistant = nil
+ @assistant_inbox = nil
end
def perform!
@@ -61,8 +72,10 @@ class Seeders::Reports::ReportDataSeeder
create_labels
create_inboxes
create_contacts
+ create_assistant
create_conversations
+ create_assistant_conversations
puts "Completed reports data seeding for account: #{@account.name}"
end
@@ -71,6 +84,7 @@ class Seeders::Reports::ReportDataSeeder
def clear_existing_data
puts "Clearing existing data for account: #{@account.id}"
+ clear_assistant_data
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@@ -80,6 +94,16 @@ class Seeders::Reports::ReportDataSeeder
@account.reporting_events.destroy_all
end
+ # Delete Captain records directly (assistant associations are destroy_async, which
+ # would leave rows around mid-reseed); order respects foreign keys.
+ def clear_assistant_data
+ assistant_ids = Captain::Assistant.for_account(@account.id).select(:id)
+ Captain::AssistantResponse.by_account(@account.id).delete_all
+ Captain::Document.for_account(@account.id).delete_all
+ CaptainInbox.where(captain_assistant_id: assistant_ids).delete_all
+ Captain::Assistant.for_account(@account.id).delete_all
+ end
+
def create_teams
TOTAL_TEAMS.times do |i|
team = @account.teams.create!(
@@ -208,6 +232,80 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
+ # One assistant, bound to a single web inbox (the first one), as the overview page expects.
+ def create_assistant
+ @account.enable_features!('captain_integration', 'captain_integration_v2')
+ @assistant_inbox = @inboxes.first
+ @assistant = Captain::Assistant.create!(
+ account: @account,
+ name: "#{Faker::Company.name} Copilot",
+ description: 'Captain assistant handling website support conversations.',
+ config: { feature_faq: true, feature_memory: true, product_name: @account.name }
+ )
+ CaptainInbox.create!(captain_assistant: @assistant, inbox: @assistant_inbox)
+ create_assistant_knowledge
+
+ puts "Created assistant '#{@assistant.name}' for inbox '#{@assistant_inbox.name}'"
+ end
+
+ def create_assistant_knowledge
+ ASSISTANT_KNOWLEDGE_APPROVED.times { create_assistant_response(:approved) }
+ ASSISTANT_KNOWLEDGE_PENDING.times { create_assistant_response(:pending) }
+
+ ASSISTANT_DOCUMENTS.times do
+ Captain::Document.create!(
+ account: @account,
+ assistant: @assistant,
+ name: Faker::Company.catch_phrase,
+ external_link: "https://#{Faker::Internet.domain_name}/#{Faker::Internet.slug}",
+ content: Faker::Lorem.paragraphs(number: rand(2..4)).join("\n\n"),
+ status: :available,
+ sync_status: :synced
+ )
+ end
+ end
+
+ def create_assistant_response(status)
+ Captain::AssistantResponse.create!(
+ account: @account,
+ assistant: @assistant,
+ question: "#{Faker::Lorem.sentence(word_count: rand(4..8)).chomp('.')}?",
+ answer: Faker::Lorem.paragraph(sentence_count: rand(2..4)),
+ status: status
+ )
+ end
+
+ def create_assistant_conversations
+ creator = Seeders::Reports::AssistantConversationCreator.new(
+ account: @account,
+ assistant: @assistant,
+ inbox: @assistant_inbox,
+ resources: { contacts: @contacts, agents: @agents }
+ )
+
+ outcomes = assistant_outcome_distribution
+ outcomes.each_with_index do |outcome, i|
+ created_at = Faker::Time.between(from: 65.days.ago, to: END_DATE)
+ creator.create_conversation(created_at: created_at, outcome: outcome)
+
+ print "\rCreating assistant conversations: #{i + 1}/#{outcomes.size}"
+ end
+
+ print "\n"
+ end
+
+ # Weighted mix of outcomes so every overview metric has meaningful numbers, shuffled
+ # so they interleave across the time span rather than clustering by type.
+ def assistant_outcome_distribution
+ counts = {
+ resolved_by_assistant: (TOTAL_ASSISTANT_CONVERSATIONS * 0.4).round,
+ handled_by_both: (TOTAL_ASSISTANT_CONVERSATIONS * 0.25).round,
+ handed_off: (TOTAL_ASSISTANT_CONVERSATIONS * 0.2).round,
+ resolved_and_reopened: (TOTAL_ASSISTANT_CONVERSATIONS * 0.15).round
+ }
+ counts.flat_map { |outcome, count| [outcome] * count }.shuffle
+ end
+
def create_conversations
conversation_creator = Seeders::Reports::ConversationCreator.new(
account: @account,
@@ -231,4 +329,4 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
end
-# rubocop:enable Rails/Output
+# rubocop:enable Rails/Output, Metrics/ClassLength
diff --git a/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb b/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb
index 6f1a068ee..3b6ff2393 100644
--- a/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/assignable_agents_controller_spec.rb
@@ -62,6 +62,24 @@ RSpec.describe 'Assignable Agents API', type: :request do
expect(response_data.size).to eq(2)
expect(response_data.pluck(:role)).to include('agent', 'administrator')
end
+
+ context 'with Agent Bots' do
+ let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
+ let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
+
+ it 'returns assignable agents and accessible agent bots' do
+ get "/api/v1/accounts/#{account.id}/assignable_agents",
+ params: { inbox_ids: [inbox1.id, inbox2.id], include_agent_bots: true },
+ headers: agent1.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ response_data = response.parsed_body['payload']
+ expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
+ expect(response_data.pluck('name')).to include(agent1.name, admin.name, account_bot.name, global_bot.name)
+ end
+ end
end
end
end
diff --git a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
index dfc2e4ff0..db7ca93a5 100644
--- a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
source: 'default'
)
end
+
+ it 'returns the assistant YAML default for V1 accounts' do
+ get "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :assistant)).to include(
+ default: Llm::Models.default_model_for('assistant'),
+ selected: Llm::Models.default_model_for('assistant'),
+ source: 'default'
+ )
+ end
+
+ it 'returns GPT-5.2 as the assistant default for V2 accounts' do
+ account.enable_features!('captain_integration_v2')
+
+ get "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :assistant)).to include(
+ default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
+ selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
+ source: 'default'
+ )
+ end
+
+ it 'keeps the V2 assistant default when an account override is selected' do
+ account.enable_features!('captain_integration_v2')
+ account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
+
+ get "/api/v1/accounts/#{account.id}/captain/preferences",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response.dig(:features, :assistant)).to include(
+ default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
+ selected: 'gpt-5.1',
+ source: 'account_override'
+ )
+ end
end
end
diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
index 9ab8d7316..766fd3b6b 100644
--- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
@@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)')
end
+ it 'returns a customer-safe error when the database query is canceled' do
+ message_builder = instance_double(Messages::MessageBuilder)
+ allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
+ allow(message_builder).to receive(:perform)
+ .and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout')
+
+ post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
+ params: { content: 'test-message', private: true },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled'))
+ expect(response.parsed_body['error']).not_to include('PG::QueryCanceled')
+ end
+
it 'creates an outgoing text message with a specific bot sender' do
agent_bot = create(:agent_bot)
time_stamp = Time.now.utc.to_s
diff --git a/spec/controllers/api/v1/widget/contacts_controller_spec.rb b/spec/controllers/api/v1/widget/contacts_controller_spec.rb
index 7abbcf22e..1d1616d2e 100644
--- a/spec/controllers/api/v1/widget/contacts_controller_spec.rb
+++ b/spec/controllers/api/v1/widget/contacts_controller_spec.rb
@@ -116,6 +116,78 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
end
end
+ describe 'PATCH /api/v1/widget/contact with HMAC enforcement' do
+ let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
+ let!(:victim) { create(:contact, account: account, identifier: 'victim-identifier', name: 'Victim') }
+ let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, 'victim-identifier') }
+
+ context 'when an identifier is supplied on a mandatory-hmac inbox' do
+ it 'rejects when identifier_hash is omitted' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier', name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'rejects when identifier_hash is blank' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: '', name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'rejects when identifier_hash is null' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: nil, name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'rejects when identifier_hash is invalid' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
+ identifier_hash: 'DEFINITELY_INVALID_AAAAA_NOT_A_REAL_HMAC', name: 'Attacker' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(victim.reload.name).to eq('Victim')
+ end
+
+ it 'succeeds when a valid identifier_hash is provided' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
+ identifier_hash: correct_identifier_hash, name: 'Legit' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'when no identifier is supplied (anonymous prechat update)' do
+ it 'allows updating name/email without an identifier_hash' do
+ patch '/api/v1/widget/contact',
+ params: { website_token: web_widget.website_token, email: 'prechat@test.com', name: 'Prechat User' },
+ headers: { 'X-Auth-Token' => token },
+ as: :json
+
+ expect(victim.reload.email).to be_nil
+ expect(Contact.from_email('prechat@test.com')).to be_present
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+
describe 'PATCH /api/v1/widget/contact/set_user' do
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb
index b2f4ff405..366e178cd 100644
--- a/spec/controllers/super_admin/accounts_controller_spec.rb
+++ b/spec/controllers/super_admin/accounts_controller_spec.rb
@@ -65,6 +65,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
+
+ it 'shows the Captain V2 assistant default in the model selector', if: ChatwootApp.enterprise? do
+ account.enable_features!('captain_integration_v2')
+ sign_in(super_admin, scope: :super_admin)
+
+ get "/super_admin/accounts/#{account.id}/edit"
+
+ document = Nokogiri::HTML(response.body)
+ assistant_select = document.at_css('select[name="account[captain_models][assistant]"]')
+ default_model_id = Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL
+ default_model = Llm::Models.model_config(default_model_id)['display_name']
+
+ expect(response).to have_http_status(:success)
+ expect(assistant_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
+ end
end
end
@@ -97,6 +112,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
it 'rejects invalid Captain model overrides' do
sign_in(super_admin, scope: :super_admin)
+ existing_captain_models = account.captain_models
patch "/super_admin/accounts/#{account.id}",
params: {
@@ -112,7 +128,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('not a valid model for label_suggestion')
- expect(account.reload.captain_models).to be_nil
+ expect(account.reload.captain_models).to eq(existing_captain_models)
end
end
end
diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
new file mode 100644
index 000000000..6575c9856
--- /dev/null
+++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
@@ -0,0 +1,271 @@
+require 'rails_helper'
+
+RSpec.describe Captain::AssistantStatsBuilder do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+
+ before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
+
+ describe '#metrics' do
+ # Two conversations handled in the current 30-day window, one in the previous.
+ let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
+ let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
+ let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ [current_convo_a, current_convo_b].each do |conversation|
+ create(:message, account: account, inbox: inbox, conversation: conversation,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
+ end
+ create(:message, account: account, inbox: inbox, conversation: previous_convo,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
+ end
+
+ it 'returns every metric for the current and previous window' do
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics.keys).to contain_exactly(
+ :conversations_handled, :auto_resolution_rate, :handoff_rate,
+ :hours_saved, :reopen_rate, :conversation_depth, :knowledge
+ )
+ expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
+ end
+
+ it 'counts distinct handled conversations per window and the percent trend' do
+ handled = described_class.new(assistant, '30').metrics[:conversations_handled]
+
+ expect(handled[:current]).to eq(2)
+ expect(handled[:previous]).to eq(1)
+ expect(handled[:trend]).to eq(100.0)
+ end
+
+ it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved')
+ create(:reporting_event, account: account, conversation: current_convo_b,
+ name: 'conversation_captain_inference_handoff')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'does not count a bot resolve as an auto-resolution when the conversation was handed off' do
+ # convo_a: handoff, customer goes quiet, resolve lands without an agent message, so the
+ # listener still emits conversation_bot_resolved for the handed-off conversation. It must
+ # not count as an auto-resolution, but still counts as a handoff.
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_bot_handoff')
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_bot_resolved')
+ # convo_b: a clean bot resolve with no handoff still counts, so the exclusion is scoped
+ # to handed-off conversations and doesn't drop every bot resolve.
+ create(:reporting_event, account: account, conversation: current_convo_b,
+ name: 'conversation_bot_resolved')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'still counts an inference resolve when the conversation was also handed off' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_handoff')
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved')
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
+ expect(metrics[:handoff_rate][:current]).to eq(50.0)
+ end
+
+ it 'excludes resolution events that fall outside the current window' do
+ create(:reporting_event, account: account, conversation: current_convo_a,
+ name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
+
+ metrics = described_class.new(assistant, '30').metrics
+
+ expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
+ end
+
+ it 'computes conversation depth as public replies per handled conversation' do
+ depth = described_class.new(assistant, '30').metrics[:conversation_depth]
+
+ # 2 public outgoing replies across 2 distinct conversations in the current window.
+ expect(depth[:current]).to eq(1.0)
+ end
+
+ it 'ignores private notes and incoming messages when counting public replies' do
+ create(:message, account: account, inbox: inbox, conversation: current_convo_a,
+ sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
+
+ depth = described_class.new(assistant, '30').metrics[:conversation_depth]
+
+ expect(depth[:current]).to eq(1.0)
+ end
+ end
+
+ describe 'range handling' do
+ it 'accepts the allowed day and named ranges' do
+ %w[7 30 90 this_month last_month].each do |allowed|
+ expect(described_class.new(assistant, allowed).range).to eq(allowed)
+ end
+ end
+
+ it 'falls back to the default range for values outside the allowed set' do
+ expect(described_class.new(assistant, '365000').range).to eq('30')
+ expect(described_class.new(assistant, 'bogus').range).to eq('30')
+ expect(described_class.new(assistant, nil).range).to eq('30')
+ end
+ end
+
+ describe '#metrics reopen_rate' do
+ # A conversation the assistant handled (messaged) inside the current 30-day window.
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before do
+ create(:message, account: account, inbox: inbox, conversation: conversation,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
+ end
+
+ it 'counts a reopen that happened after the captain resolve' do
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+
+ it 'ignores a human resolve/reopen that happened before the captain resolve' do
+ # Earlier resolve/reopen cycle, then Captain resolves later in the same window.
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
+ end
+
+ it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
+ # Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
+ # only holds the inference event, which is dispatched a moment after the generic conversation_resolved
+ # that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved',
+ event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+
+ it 'counts both inference and time-based bot resolves in the denominator' do
+ # conversation: inference-resolved and reopened
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+ # other: time-based bot-resolved, never reopened
+ other = create(:conversation, account: account, inbox: inbox)
+ create(:message, account: account, inbox: inbox, conversation: other,
+ sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: other,
+ name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
+ end
+
+ it 'ignores a reopen that landed after a completed window ended' do
+ travel_to(Time.utc(2026, 7, 15)) do
+ convo = create(:conversation, account: account, inbox: inbox)
+ create(:message, account: account, inbox: inbox, conversation: convo,
+ sender: assistant, message_type: :outgoing, private: false, created_at: Time.utc(2026, 6, 10))
+ create(:reporting_event, account: account, inbox: inbox, conversation: convo,
+ name: 'conversation_bot_resolved', created_at: Time.utc(2026, 6, 12),
+ event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 6, 12))
+ # Reopened on July 1, after the June window closed; June's rate must not count it.
+ create(:reporting_event, account: account, inbox: inbox, conversation: convo,
+ name: 'conversation_opened', value: 120,
+ event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 7, 1))
+
+ expect(described_class.new(assistant, 'last_month').metrics[:reopen_rate][:current]).to eq(0.0)
+ end
+ end
+
+ it 'derives the cohort from handled conversations, not current inbox membership' do
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
+ create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
+ name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
+ # The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
+ CaptainInbox.where(captain_assistant: assistant).delete_all
+
+ expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
+ end
+ end
+
+ describe 'timezone anchoring' do
+ # 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
+ it 'anchors the this_month window to the supplied offset, not UTC' do
+ travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
+ utc = described_class.new(assistant, 'this_month').period
+ la = described_class.new(assistant, 'this_month', -7).period
+
+ expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
+ expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
+ expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
+ end
+ end
+
+ it 'defaults to UTC when no offset is given' do
+ travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
+ expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
+ end
+ end
+ end
+
+ describe '#metrics knowledge' do
+ before do
+ create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
+ create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
+ create_list(:captain_document, 2, assistant: assistant, account: account)
+ end
+
+ it 'returns approved, pending, document counts and coverage' do
+ knowledge = described_class.new(assistant, '30').metrics[:knowledge]
+
+ expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
+ end
+
+ it 'reports zero coverage when there are no responses' do
+ Captain::AssistantResponse.where(assistant: assistant).delete_all
+
+ knowledge = described_class.new(assistant, '30').metrics[:knowledge]
+
+ expect(knowledge[:coverage]).to eq(0)
+ end
+ end
+
+ describe '#period' do
+ it 'labels a day range and exposes its bounds' do
+ period = described_class.new(assistant, '30').period
+
+ expect(period[:label]).to eq('the last 30 days')
+ expect(period[:starts_on]).to eq(30.days.ago.to_date)
+ expect(period[:ends_on]).to eq(Time.zone.today)
+ end
+
+ it 'labels the this_month range' do
+ expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
+ end
+
+ it 'labels the last_month range' do
+ expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
new file mode 100644
index 000000000..33cc95fc1
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Callbacks API', type: :request do
+ describe 'POST /api/v1/accounts/{account.id}/callbacks/register_facebook_page' do
+ let(:account) { create(:account, limits: { inboxes: 1 }) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:params) do
+ {
+ user_access_token: 'user-token',
+ page_access_token: 'page-token',
+ page_id: '12345',
+ inbox_name: 'Facebook Inbox'
+ }
+ end
+
+ before do
+ create(:inbox, account: account)
+ end
+
+ it 'returns payment required before creating a Facebook channel when account inbox limit is reached' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/callbacks/register_facebook_page",
+ headers: admin.create_new_auth_token,
+ params: params,
+ as: :json
+ end.not_to change(Channel::FacebookPage, :count)
+
+ expect(response).to have_http_status(:payment_required)
+ expect(response.parsed_body['error']).to eq('Account limit exceeded. Upgrade to a higher plan')
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
new file mode 100644
index 000000000..86e4fb317
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+RSpec.describe 'Calls API', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, :with_phone_number, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let!(:agent_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact,
+ accepted_by_agent: agent, status: 'completed', transcript: 'hello world')
+ end
+ let!(:other_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, accepted_by_agent: admin)
+ end
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ describe 'GET /api/v1/accounts/:account_id/calls' do
+ it 'returns 401 when unauthenticated' do
+ get "/api/v1/accounts/#{account.id}/calls"
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns the whole account with sensitive fields for an administrator' do
+ get "/api/v1/accounts/#{account.id}/calls", headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id, other_call.id)
+ item = body['payload'].find { |c| c['id'] == agent_call.id }
+ expect(item['transcript']).to eq('hello world')
+ expect(item['contact']['phone_number']).to eq(contact.phone_number)
+ end
+
+ it 'scopes the list to calls the agent accepted' do
+ get "/api/v1/accounts/#{account.id}/calls", headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['meta']['count']).to eq(1)
+ expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id)
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index 4689defaf..afb6aa2de 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -252,6 +252,48 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
+ describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
+ let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
+ let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
+
+ def get_summary(user)
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
+ params: { range: '30' },
+ headers: user.create_new_auth_token,
+ as: :json
+ end
+
+ before do
+ # Test env uses a null store; swap in a real store so caching behaviour is observable.
+ allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
+ allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
+ end
+
+ it 'caches the summary per viewer so one user never receives another user\'s greeting' do
+ allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
+
+ get_summary(alice)
+ get_summary(alice) # served from Alice's cache, no regeneration
+ get_summary(bob) # distinct cache key, regenerated for Bob
+
+ expect(response).to have_http_status(:success)
+ expect(Captain::OverviewSummaryService).to have_received(:new).twice
+ end
+
+ it 'does not cache failures so a transient error is retried' do
+ allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
+
+ get_summary(alice)
+ get_summary(alice)
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(json_response[:error]).to eq('LLM unavailable')
+ expect(Captain::OverviewSummaryService).to have_received(:new).twice
+ end
+ end
+
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) do
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
index b2a920b07..cfabf6b7e 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
@@ -256,6 +256,14 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
+ create(:installation_config, name: 'CAPTAIN_TOPUP_OPTIONS', value: {
+ 'usd' => [
+ { 'credits' => 1000, 'amount' => 20.0 },
+ { 'credits' => 2500, 'amount' => 50.0 },
+ { 'credits' => 6000, 'amount' => 100.0 },
+ { 'credits' => 12_000, 'amount' => 200.0 }
+ ]
+ })
end
it 'returns unauthorized for unauthenticated user' do
diff --git a/spec/enterprise/finders/call_finder_spec.rb b/spec/enterprise/finders/call_finder_spec.rb
new file mode 100644
index 000000000..4f4607370
--- /dev/null
+++ b/spec/enterprise/finders/call_finder_spec.rb
@@ -0,0 +1,108 @@
+require 'rails_helper'
+
+describe CallFinder do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ before { create(:inbox_member, user: agent, inbox: inbox) }
+
+ def perform(user, params = {})
+ Current.account = account
+ Current.account_user = account.account_users.find_by(user_id: user.id)
+ described_class.new(user, account, params).perform
+ end
+
+ describe 'visibility' do
+ let!(:agent_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: agent)
+ end
+ let!(:other_call) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: admin)
+ end
+
+ it 'lets an administrator see every call in the account' do
+ result = perform(admin)
+ expect(result[:count]).to eq(2)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
+ end
+
+ it 'lets an agent with report_manage see every call in the account' do
+ report_manager = create(:user, account: account, role: :agent)
+ custom_role = create(:custom_role, account: account, permissions: ['report_manage'])
+ account.account_users.find_by(user_id: report_manager.id).update!(custom_role: custom_role)
+
+ result = perform(report_manager)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
+ end
+
+ it 'limits a regular agent to calls they accepted in accessible conversations' do
+ result = perform(agent)
+ expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id)
+ end
+
+ it 'limits a custom-role agent without report_manage to their own accepted calls' do
+ scoped_agent = create(:user, account: account, role: :agent)
+ custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
+ account.account_users.find_by(user_id: scoped_agent.id).update!(custom_role: custom_role)
+ create(:inbox_member, user: scoped_agent, inbox: inbox)
+ scoped_call = create(:call, account: account, inbox: inbox, conversation: conversation,
+ contact: conversation.contact, accepted_by_agent: scoped_agent)
+
+ result = perform(scoped_agent)
+ expect(result[:calls].map(&:id)).to contain_exactly(scoped_call.id)
+ end
+ end
+
+ describe 'filters' do
+ let(:inbox2) { create(:inbox, account: account) }
+ let(:conversation2) { create(:conversation, account: account, inbox: inbox2) }
+ let(:agent2) { create(:user, account: account, role: :agent) }
+ let!(:ringing) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
+ status: 'ringing', direction: :incoming, accepted_by_agent: agent)
+ end
+ let!(:in_progress) do
+ create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
+ status: 'in_progress', direction: :incoming, accepted_by_agent: agent)
+ end
+ let!(:completed) do
+ create(:call, account: account, inbox: inbox2, conversation: conversation2, contact: conversation2.contact,
+ status: 'completed', direction: :outgoing, accepted_by_agent: agent2, created_at: 10.days.ago)
+ end
+
+ it 'filters by status using the display value' do
+ expect(perform(admin, status: 'in-progress')[:calls].map(&:id)).to contain_exactly(in_progress.id)
+ end
+
+ it 'filters by direction using the display label' do
+ expect(perform(admin, direction: 'outbound')[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by inbox' do
+ expect(perform(admin, inbox_id: inbox2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by agent' do
+ expect(perform(admin, agent_id: agent2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
+ end
+
+ it 'filters by created_at date range' do
+ params = { since: 2.days.ago.to_i.to_s, until: 1.hour.from_now.to_i.to_s }
+ expect(perform(admin, params)[:calls].map(&:id)).to contain_exactly(ringing.id, in_progress.id)
+ end
+ end
+
+ describe 'account scoping' do
+ it 'never returns calls from another account' do
+ other_account = create(:account)
+ other_conversation = create(:conversation, account: other_account)
+ create(:call, account: other_account, inbox: other_conversation.inbox, conversation: other_conversation,
+ contact: other_conversation.contact)
+
+ expect(perform(admin)[:count]).to eq(0)
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index fb970f726..9186874b7 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -5,6 +5,13 @@ RSpec.describe Captain::BaseTaskService, type: :model do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:perform_result) { { message: 'Test response' } }
+ let(:exhausted_usage_limits) do
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 0 } }
+ }
+ end
# Create a concrete test service class with enterprise module prepended
let(:test_service_class) do
@@ -38,9 +45,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when usage limit is exceeded' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error' do
@@ -125,9 +130,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error for services that do not opt into BYOK' do
@@ -162,9 +165,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
@@ -249,9 +250,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
index 8b059acc3..80b9ab1d8 100644
--- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
+++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
@@ -166,9 +166,13 @@ RSpec.describe Captain::ConversationCompletionService do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
- allow(account).to receive(:usage_limits).and_return({
- captain: { responses: { current_available: 0 } }
- })
+ allow(account).to receive(:usage_limits).and_return(
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 0 } }
+ }
+ )
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
allow(mock_chat).to receive(:ask).and_return(mock_response)
diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb
index c69a83256..03f3b8d37 100644
--- a/spec/enterprise/models/account_spec.rb
+++ b/spec/enterprise/models/account_spec.rb
@@ -222,6 +222,37 @@ RSpec.describe Account, type: :model do
end
end
+ describe 'default features' do
+ before do
+ InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
+ value: Featurable::FEATURE_LIST,
+ locked: true
+ )
+ end
+
+ it 'enables Captain V2 for new self-hosted enterprise accounts' do
+ allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
+
+ account = create(:account)
+
+ expect(account).to be_feature_enabled('captain_integration')
+ expect(account).to be_feature_enabled('captain_integration_v2')
+ expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
+ expect(account.captain_models).to be_nil
+ end
+
+ it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
+ allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+
+ account = create(:account)
+
+ expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
+ expect(account).not_to be_feature_enabled('captain_integration')
+ expect(account).not_to be_feature_enabled('captain_integration_v2')
+ end
+ end
+
describe 'captain document sync cadence' do
let(:account) { create(:account) }
diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb
index d179393c9..f2145ff85 100644
--- a/spec/enterprise/models/concerns/agentable_spec.rb
+++ b/spec/enterprise/models/concerns/agentable_spec.rb
@@ -179,6 +179,14 @@ RSpec.describe Concerns::Agentable do
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
end
+ it 'returns the Captain V2 default when Captain V2 is enabled' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
+ account.enable_features!('captain_integration_v2')
+
+ expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
+ expect(account.reload.captain_models).to be_nil
+ end
+
it 'returns the assistant feature default model when account is nil' do
agent = dummy_class.new(account: nil)
diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb
index cfcbdd573..1dce2833b 100644
--- a/spec/enterprise/models/inbox_spec.rb
+++ b/spec/enterprise/models/inbox_spec.rb
@@ -134,6 +134,29 @@ RSpec.describe Inbox do
end
end
+ describe 'validations' do
+ describe 'account inbox limit' do
+ let(:account) { create(:account, limits: { inboxes: 1 }) }
+
+ before do
+ create(:inbox, account: account)
+ end
+
+ it 'prevents saving inboxes beyond the account limit' do
+ new_inbox = build(:inbox, account: account)
+
+ expect { new_inbox.save! }.to raise_error(CustomExceptions::Inbox::LimitExceeded, 'Account limit exceeded. Upgrade to a higher plan')
+ end
+
+ it 'does not block updates to existing inboxes when the account is at the limit' do
+ inbox = account.inboxes.first
+ inbox.name = 'Updated Inbox'
+
+ expect(inbox).to be_valid
+ end
+ end
+ end
+
describe 'audit log' do
context 'when inbox is created' do
it 'has associated audit log created' do
diff --git a/spec/enterprise/policies/captain/assistant_policy_spec.rb b/spec/enterprise/policies/captain/assistant_policy_spec.rb
index e3c62846f..e04b680e4 100644
--- a/spec/enterprise/policies/captain/assistant_policy_spec.rb
+++ b/spec/enterprise/policies/captain/assistant_policy_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
end
end
- permissions :tools?, :create?, :update?, :destroy?, :sync? do
+ permissions :tools?, :create?, :update?, :destroy?, :sync?, :drilldown? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
index d2dbf646a..a7b2e8322 100644
--- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
@@ -82,7 +82,8 @@ describe Enterprise::Billing::CreateStripeCustomerService do
subscribed_quantity: 2,
plan_name: 'A Plan Name',
subscription_status: 'active',
- subscription_ends_on: subscription_ends_on
+ subscription_ends_on: subscription_ends_on,
+ billing_currency: 'usd'
}.with_indifferent_access
)
end
@@ -95,7 +96,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
create_stripe_customer_service.new(account: account).perform
- expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
+ expect(Stripe::Customer).to have_received(:create).with(
+ { name: account.name, email: admin1.email }
+ )
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
@@ -108,10 +111,27 @@ describe Enterprise::Billing::CreateStripeCustomerService do
subscribed_quantity: 2,
plan_name: 'A Plan Name',
subscription_status: 'active',
- subscription_ends_on: subscription_ends_on
+ subscription_ends_on: subscription_ends_on,
+ billing_currency: 'usd'
}.with_indifferent_access
)
end
+
+ it 'sets the billing country override when the account currency requires it' do
+ with_modified_env ENABLE_MULTI_CURRENCY_BILLING: 'true' do
+ account.update!(custom_attributes: { billing_currency: 'brl' })
+ customer = double
+ allow(Stripe::Customer).to receive(:create).and_return(customer)
+ allow(customer).to receive(:id).and_return('cus_random_number')
+ allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
+
+ create_stripe_customer_service.new(account: account).perform
+
+ expect(Stripe::Customer).to have_received(:create).with(
+ { name: account.name, email: admin1.email, address: { country: 'BR' }, preferred_locales: ['pt-BR'] }
+ )
+ end
+ end
end
describe 'when checking for existing subscriptions' do
diff --git a/spec/enterprise/services/enterprise/billing/currencies_spec.rb b/spec/enterprise/services/enterprise/billing/currencies_spec.rb
new file mode 100644
index 000000000..84172137e
--- /dev/null
+++ b/spec/enterprise/services/enterprise/billing/currencies_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+describe Enterprise::Billing::Currencies do
+ describe 'Brazilian Real (brl)' do
+ it 'is a supported currency' do
+ expect(described_class.supported?('brl')).to be(true)
+ end
+
+ it 'recognizes brl regardless of casing or surrounding whitespace' do
+ expect(described_class.supported?(' BRL ')).to be(true)
+ expect(described_class.normalize(' BRL ')).to eq('brl')
+ end
+
+ it 'keeps brl when coercing to a supported code' do
+ expect(described_class.to_supported('BRL')).to eq('brl')
+ end
+
+ it 'defaults the pt_BR account locale to brl' do
+ expect(described_class.for_locale('pt_BR')).to eq('brl')
+ end
+
+ it 'maps brl to Brazil and the pt-BR checkout locale' do
+ expect(described_class.country_for('brl')).to eq('BR')
+ expect(described_class.preferred_locale_for('brl')).to eq('pt-BR')
+ end
+
+ it 'falls back to the usd default for unsupported input' do
+ expect(described_class.to_supported('eur')).to eq('usd')
+ end
+
+ it 'does not set a country override for usd customers' do
+ expect(described_class.country_for('usd')).to be_nil
+ expect(described_class.preferred_locale_for('usd')).to be_nil
+ end
+ end
+end
diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
index 3223efa86..5116e1ec3 100644
--- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
@@ -175,6 +175,7 @@ describe Enterprise::Billing::HandleStripeEventService do
described_class::STARTUP_PLAN_FEATURES.each do |feature|
account.enable_features(feature)
end
+ account.enable_features('captain_integration_v2')
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
account.save!
@@ -193,6 +194,7 @@ describe Enterprise::Billing::HandleStripeEventService do
all_features.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
+ expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
@@ -218,6 +220,29 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled(feature)
end
end
+
+ it 'does not enable Captain V2 for existing paid accounts during reconciliation' do
+ allow(subscription).to receive(:[]).with('plan')
+ .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
+
+ stripe_event_service.new.perform(event: event)
+
+ expect(account.reload).not_to be_feature_enabled('captain_integration_v2')
+ end
+
+ it 'enables Captain V2 for new cloud accounts marked as default eligible' do
+ account.update!(
+ internal_attributes: account.internal_attributes.merge(
+ Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE => true
+ )
+ )
+ allow(subscription).to receive(:[]).with('plan')
+ .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
+
+ stripe_event_service.new.perform(event: event)
+
+ expect(account.reload).to be_feature_enabled('captain_integration_v2')
+ end
end
context 'with Business plan' do
diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
index fa4c052a1..1836cb39d 100644
--- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
@@ -15,6 +15,15 @@ describe Enterprise::Billing::TopupCheckoutService do
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
+ create(:installation_config, name: 'CAPTAIN_TOPUP_OPTIONS', value: {
+ 'usd' => [
+ { 'credits' => 1000, 'amount' => 20.0 },
+ { 'credits' => 2500, 'amount' => 50.0 },
+ { 'credits' => 6000, 'amount' => 100.0 },
+ { 'credits' => 12_000, 'amount' => 200.0 }
+ ]
+ })
+
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 500 }
diff --git a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb
index 63a318415..76e9e3602 100644
--- a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb
+++ b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb
@@ -11,14 +11,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'disables the premium features for accounts' do
account = create(:account)
- account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
+ account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
- account_with_captain.enable_features!('captain_integration')
+ account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
- expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
- expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
+ expect(account.reload.enabled_features.keys).not_to include(
+ 'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
+ )
+ expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
@@ -56,14 +58,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'does not disable the premium features for accounts' do
account = create(:account)
- account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
+ account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
- account_with_captain.enable_features!('captain_integration')
+ account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
- expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
- expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
+ expect(account.reload.enabled_features.keys).to include(
+ 'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
+ )
+ expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end
diff --git a/spec/enterprise/services/llm/base_ai_service_spec.rb b/spec/enterprise/services/llm/base_ai_service_spec.rb
index f18752485..d4c07d336 100644
--- a/spec/enterprise/services/llm/base_ai_service_spec.rb
+++ b/spec/enterprise/services/llm/base_ai_service_spec.rb
@@ -30,6 +30,14 @@ RSpec.describe Llm::BaseAiService do
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano')
end
+ it 'uses the Captain V2 assistant default ahead of the installation model' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
+ account.enable_features!('captain_integration_v2')
+
+ expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
+ expect(account.reload.captain_models).to be_nil
+ end
+
it 'uses the feature default when feature context has no account override or installation model' do
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::Models.default_model_for('assistant'))
end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 265ce6c33..4881e8cf1 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -12,7 +12,13 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
# Mock usage limits for transcription to be available
- allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
+ allow(account).to receive(:usage_limits).and_return(
+ {
+ agents: ChatwootApp.max_limit,
+ inboxes: ChatwootApp.max_limit,
+ captain: { responses: { current_available: 100 } }
+ }
+ )
end
describe '#perform' do
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index b24a5c49c..2cb24ce04 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -385,7 +385,10 @@ RSpec.describe Captain::BaseTaskService do
describe '#prompt_from_file' do
it 'reads prompt from file' do
- allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
+ service
+ prompt_path = instance_double(Pathname, read: 'Test prompt content')
+ allow(Rails.root).to receive(:join).with('lib/integrations/openai/openai_prompts', 'test.liquid').and_return(prompt_path)
+
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
end
end
diff --git a/spec/lib/llm/feature_router_spec.rb b/spec/lib/llm/feature_router_spec.rb
index e0eb4afa0..ea2b9f91f 100644
--- a/spec/lib/llm/feature_router_spec.rb
+++ b/spec/lib/llm/feature_router_spec.rb
@@ -30,6 +30,32 @@ RSpec.describe Llm::FeatureRouter do
)
end
+ it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
+ account.enable_features!('captain_integration_v2')
+
+ resolved = described_class.resolve(feature: 'assistant', account: account)
+
+ expect(resolved).to include(
+ feature: 'assistant',
+ provider: 'openai',
+ model: 'gpt-5.2',
+ source: :default
+ )
+ expect(account.reload.captain_models).to be_nil
+ end
+
+ it 'keeps account model overrides ahead of the Captain V2 default' do
+ account.enable_features!('captain_integration_v2')
+ account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
+
+ resolved = described_class.resolve(feature: 'assistant', account: account)
+
+ expect(resolved).to include(
+ model: 'gpt-5.1',
+ source: :account_override
+ )
+ end
+
it 'falls back to the feature default when the account override is invalid' do
account.captain_models = { 'editor' => 'invalid-model' }
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 56bd41f7c..e3bc636d1 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -50,6 +50,21 @@ RSpec.describe Account do
end
end
+ describe 'captain defaults for new accounts' do
+ it 'does not store Captain model overrides or enable premium Captain features' do
+ InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
+ value: Featurable::FEATURE_LIST,
+ locked: true
+ )
+
+ account = create(:account)
+
+ expect(account).not_to be_feature_enabled('captain_integration')
+ expect(account).not_to be_feature_enabled('captain_integration_v2')
+ expect(account.captain_models).to be_nil
+ end
+ end
+
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
@@ -337,6 +352,10 @@ RSpec.describe Account do
let(:account) { create(:account) }
describe 'with no saved preferences' do
+ before do
+ account.update!(captain_models: nil)
+ end
+
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
@@ -346,6 +365,13 @@ RSpec.describe Account do
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
+
+ it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
+ account.enable_features!('captain_integration_v2')
+
+ expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
+ expect(account.reload.captain_models).to be_nil
+ end
end
describe 'with saved model preferences' do
diff --git a/spec/models/concerns/captain_featurable_spec.rb b/spec/models/concerns/captain_featurable_spec.rb
index 7221ad055..0fb145142 100644
--- a/spec/models/concerns/captain_featurable_spec.rb
+++ b/spec/models/concerns/captain_featurable_spec.rb
@@ -58,15 +58,6 @@ RSpec.describe CaptainFeaturable do
end
describe 'model accessor methods' do
- context 'when no models are explicitly configured' do
- it 'returns default models for all features' do
- Llm::Models.feature_keys.each do |feature_key|
- expected_default = Llm::Models.default_model_for(feature_key)
- expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
- end
- end
- end
-
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {
diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb
index cb55dba61..8272b5925 100644
--- a/spec/models/team_spec.rb
+++ b/spec/models/team_spec.rb
@@ -7,6 +7,31 @@ RSpec.describe Team do
it { is_expected.to have_many(:team_members) }
end
+ describe 'name normalization' do
+ let(:account) { create(:account) }
+
+ it 'downcases the name' do
+ team = create(:team, account: account, name: 'Customer Support')
+ expect(team.name).to eq('customer support')
+ end
+
+ it 'strips control characters and surrounding whitespace' do
+ team = create(:team, account: account, name: " Sales\n")
+ expect(team.name).to eq('sales')
+ end
+
+ it 'removes control characters embedded within the name' do
+ team = create(:team, account: account, name: "su\npport")
+ expect(team.name).to eq('support')
+ end
+
+ it 'is invalid when the name reduces to blank after sanitization' do
+ team = build(:team, account: account, name: "\t\n ")
+ expect(team).not_to be_valid
+ expect(team.errors[:name]).to include(I18n.t('errors.validations.presence'))
+ end
+ end
+
describe '#add_members' do
let(:team) { FactoryBot.create(:team) }
diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
index 75abc8518..988fb0116 100644
--- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
+++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before do
account.enable_features('crm_integration')
+ allow(GlobalConfig).to receive(:get).and_return({})
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
end
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
index 983af6c78..e7016f6a4 100644
--- a/spec/services/whatsapp/channel_creation_service_spec.rb
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -60,6 +60,17 @@ describe Whatsapp::ChannelCreationService do
expect(inbox.name).to eq('Test Business WhatsApp')
expect(inbox.account).to eq(account)
end
+
+ it 'does not leave an orphan channel when inbox creation fails' do
+ allow(Inbox).to receive(:create!).and_wrap_original do |method, *args|
+ method.call(*args)
+ raise ActiveRecord::RecordInvalid, Inbox.new
+ end
+
+ expect do
+ expect { service.perform }.to raise_error(ActiveRecord::RecordInvalid)
+ end.not_to change(Channel::Whatsapp, :count)
+ end
end
context 'when channel already exists for the phone number' do