Class: Assistant::MonthlyBudget

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/monthly_budget.rb

Overview

Per-user monthly spend ceiling for Sunny (the CRM assistant).

Sunny cost is attributed to the human who owns the conversation
(+AssistantConversation#user_id+) — NOT +ai_usage_logs.account_id+, which
is a context/customer field, not the actor. Spend is the sum of
+cost_microdollars+ over the user's +AssistantConversation+-subject usage
in the current calendar month.

The cap is the governor for complexity-aware model escalation: while a user
has budget left they can be auto-upgraded up the model ladder
(flash -> pro -> opus -> opus-1m); once they are over, escalation is
disabled and they soft-degrade to the cheap tier rather than being cut off.

Caps (confirmed 2026-06-03): $50/user/month, $200 for managers. Managers
are +is_manager?+ accounts, which already include admins — the same
power-user split as +Assistant::ChatToolBuilder::POWER_USER_ROLES+.

Pure read: no migration, no persisted counter. Spend is recomputed per turn
(once per user message, not per tool call) against the indexed
+(subject_type, subject_id)+ lookup and memoized for the instance's life.

Examples:

Gate escalation on remaining budget

budget = Assistant::MonthlyBudget.for_user_context(user_context)
stronger_model if budget.allows_escalation?

Constant Summary collapse

DEFAULT_CAP_USD =

Default monthly cap in USD for a standard user.

50
MANAGER_CAP_USD =

Monthly cap in USD for managers (and admins, via +is_manager?+).

200
WARN_FRACTION =

Fraction of the cap at which a soft warning should be surfaced.

0.80

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(party_id:, manager: false, now: Time.current) ⇒ MonthlyBudget

Returns a new instance of MonthlyBudget.

Parameters:

  • party_id (Integer, nil)

    the conversation owner's Party id

  • manager (Boolean) (defaults to: false)

    whether the user gets the manager cap

  • now (Time) (defaults to: Time.current)

    clock injection point for tests



53
54
55
56
57
# File 'app/services/assistant/monthly_budget.rb', line 53

def initialize(party_id:, manager: false, now: Time.current)
  @party_id = party_id
  @manager = manager
  @now = now
end

Class Method Details

.for_user_context(user_context) ⇒ Assistant::MonthlyBudget

Build from the serialized user_context the controller hands the worker.

Parameters:

  • user_context (Hash)

    carries 'party_id' and 'is_manager'

Returns:



42
43
44
45
46
47
48
# File 'app/services/assistant/monthly_budget.rb', line 42

def self.for_user_context(user_context)
  ctx = user_context || {}
  new(
    party_id: ctx['party_id'] || ctx[:party_id],
    manager: ctx['is_manager'] || ctx[:is_manager] || false
  )
end

Instance Method Details

#allows_escalation?Boolean

Whether the user still has budget to be auto-upgraded to a more
expensive model. Escalation stops at the cap; it never hard-blocks an
in-flight conversation — the caller degrades to the cheap tier instead.

Returns:

  • (Boolean)


131
132
133
# File 'app/services/assistant/monthly_budget.rb', line 131

def allows_escalation?
  !over?
end

#cap_microdollarsInteger

Returns the monthly cap in microdollars (for exact integer gating).

Returns:

  • (Integer)

    the monthly cap in microdollars (for exact integer gating)



68
69
70
# File 'app/services/assistant/monthly_budget.rb', line 68

def cap_microdollars
  cap_usd * AiUsageLog::MICRODOLLARS_PER_DOLLAR
end

#cap_usdInteger

The monthly cap in whole USD for this user: a per-user override (set by an
admin on the employee page) wins, otherwise the role default.

Returns:

  • (Integer)


63
64
65
# File 'app/services/assistant/monthly_budget.rb', line 63

def cap_usd
  override_usd || (@manager ? MANAGER_CAP_USD : DEFAULT_CAP_USD)
end

#fraction_usedFloat

Fraction of the cap consumed. Float — presentation only (the % label and
progress bar); the gating predicates below use exact integer microdollars
so cap/warn boundaries don't drift on Float division.

Returns:

  • (Float)

    0.0..n



102
103
104
105
106
# File 'app/services/assistant/monthly_budget.rb', line 102

def fraction_used
  return 1.0 if cap_microdollars.zero?

  spent_microdollars.to_f / cap_microdollars
end

#over?Boolean

Returns true once spend has reached or passed the cap.

Returns:

  • (Boolean)

    true once spend has reached or passed the cap



109
110
111
# File 'app/services/assistant/monthly_budget.rb', line 109

def over?
  spent_microdollars >= cap_microdollars
end

#override_usdInteger?

Per-user budget override from the parties column, or nil when unset (→ role
default). Admins raise an individual's budget from /employees/:id. Memoized.

Returns:

  • (Integer, nil)


76
77
78
79
80
# File 'app/services/assistant/monthly_budget.rb', line 76

def override_usd
  return @override_usd if defined?(@override_usd)

  @override_usd = @party_id && Party.where(id: @party_id).pick(:monthly_ai_budget_usd)
end

#remaining_usdFloat

Returns remaining budget in USD (never negative).

Returns:

  • (Float)

    remaining budget in USD (never negative)



93
94
95
# File 'app/services/assistant/monthly_budget.rb', line 93

def remaining_usd
  [cap_microdollars - spent_microdollars, 0].max.to_f / AiUsageLog::MICRODOLLARS_PER_DOLLAR
end

#spent_microdollarsInteger

Returns month-to-date Sunny spend in microdollars.

Returns:

  • (Integer)

    month-to-date Sunny spend in microdollars



83
84
85
# File 'app/services/assistant/monthly_budget.rb', line 83

def spent_microdollars
  @spent_microdollars ||= compute_spent_microdollars
end

#spent_usdFloat

Returns month-to-date Sunny spend in USD.

Returns:

  • (Float)

    month-to-date Sunny spend in USD



88
89
90
# File 'app/services/assistant/monthly_budget.rb', line 88

def spent_usd
  spent_microdollars.to_f / AiUsageLog::MICRODOLLARS_PER_DOLLAR
end

#statusSymbol

Returns :ok, :warn, or :over.

Returns:

  • (Symbol)

    :ok, :warn, or :over



119
120
121
122
123
124
# File 'app/services/assistant/monthly_budget.rb', line 119

def status
  return :over if over?
  return :warn if warn?

  :ok
end

#warn?Boolean

Returns true once spend has reached the warning threshold.

Returns:

  • (Boolean)

    true once spend has reached the warning threshold



114
115
116
# File 'app/services/assistant/monthly_budget.rb', line 114

def warn?
  spent_microdollars >= (cap_microdollars * WARN_FRACTION).to_i
end