Improve naming & remove money accessors

This commit is contained in:
Stanko K.R.
2025-08-21 13:51:16 +02:00
parent 4505f80b8c
commit 6b89aa6805
9 changed files with 73 additions and 165 deletions
+5
View File
@@ -0,0 +1,5 @@
module Ai
def self.table_name_prefix
"ai_"
end
end
+27 -12
View File
@@ -1,18 +1,14 @@
class Ai::Quota < ApplicationRecord
class UsageExceedsQuotaError < StandardError; end
include MoneyAccessors, Resettable
self.table_name = "ai_quotas"
money_accessor :used, :limit
belongs_to :user
before_create -> { reset }
validates :limit, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :used, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
def increment_usage(cost)
def spend(cost)
cost = Money.wrap(cost)
transaction do
@@ -21,15 +17,34 @@ class Ai::Quota < ApplicationRecord
end
end
def ensure_under_limit
def ensure_not_depleted
reset_if_due
if over_limit?
if depleted?
raise UsageExceedsQuotaError
end
end
def over_limit?
used >= limit
end
private
def reset_if_due
reset if due_for_reset?
end
def reset
attributes = { used: 0, reset_at: 7.days.from_now }
if persisted?
update(**attributes)
else
assign_attributes(**attributes)
end
end
def due_for_reset?
reset_at.before?(Time.current)
end
def depleted?
used >= limit
end
end
+4 -23
View File
@@ -1,6 +1,4 @@
class Ai::Quota::Money < Data.define(:value)
include Comparable
CENTS_PER_DOLLAR = 100
MICROCENTS_PER_CENT = 1_000_000
MICROCENTS_PER_DOLLAR = CENTS_PER_DOLLAR * MICROCENTS_PER_CENT
@@ -24,9 +22,10 @@ class Ai::Quota::Money < Data.define(:value)
new(microcents)
end
def convert_dollars_to_microcents(dollars)
(dollars.to_d * MICROCENTS_PER_DOLLAR).round.to_i
end
private
def convert_dollars_to_microcents(dollars)
(dollars.to_d * MICROCENTS_PER_DOLLAR).round.to_i
end
end
def to_i
@@ -40,22 +39,4 @@ class Ai::Quota::Money < Data.define(:value)
def in_dollars
in_microcents.to_d / MICROCENTS_PER_DOLLAR
end
def <=>(other)
in_microcents <=> self.class.wrap(other).in_microcents
end
def +(other)
other = self.class.wrap(other)
self.class.new(in_microcents + other.in_microcents)
end
def -(other)
other = self.class.wrap(other)
self.class.new(in_microcents - other.in_microcents)
end
def abs
self.class.new(in_microcents.abs)
end
end
-20
View File
@@ -1,20 +0,0 @@
module Ai::Quota::MoneyAccessors
extend ActiveSupport::Concern
class_methods do
def money_accessor(*attribute_names)
attribute_names.each do |name|
define_method(name) do
if super()
Ai::Quota::Money.new(super())
end
end
define_method("#{name}=") do |value|
value = Ai::Quota::Money.wrap(value).in_microcents if value
super(value)
end
end
end
end
end
-25
View File
@@ -1,25 +0,0 @@
module Ai::Quota::Resettable
extend ActiveSupport::Concern
included do
before_create -> { reset }
end
def reset_if_due
reset if due_for_reset?
end
def reset
attributes = { used: 0, reset_at: 7.days.from_now }
if persisted?
update(**attributes)
else
assign_attributes(**attributes)
end
end
def due_for_reset?
reset_at.before?(Time.current)
end
end
+2 -2
View File
@@ -9,7 +9,7 @@ class Conversation < ApplicationRecord
enum :state, %w[ ready thinking ].index_by(&:itself), default: :ready
def ask(question, **attributes)
user.ensure_under_ai_usage_limit
user.ensure_ai_quota_not_depleted
create_message_with_state_change(**attributes, role: :user, content: question) do
raise(InvalidStateError, "Can't ask questions while thinking") if thinking?
@@ -23,7 +23,7 @@ class Conversation < ApplicationRecord
ready!
end
user.increment_ai_usage(message.cost) if message.cost
user.spend_ai_quota(message.cost) if message.cost
message
end
+8 -7
View File
@@ -5,15 +5,16 @@ module User::AiQuota
has_one :ai_quota, class_name: "Ai::Quota"
end
def fetch_or_create_ai_quota
ai_quota || create_ai_quota!(limit: Ai::Quota::Money.wrap("$100"))
def spend_ai_quota(cost)
fetch_or_create_ai_quota.spend(cost)
end
def increment_ai_usage(cost)
fetch_or_create_ai_quota.increment_usage(cost)
def ensure_ai_quota_not_depleted
fetch_or_create_ai_quota.ensure_not_depleted
end
def ensure_under_ai_usage_limit
fetch_or_create_ai_quota.ensure_under_limit
end
private
def fetch_or_create_ai_quota
ai_quota || create_ai_quota!(limit: Ai::Quota::Money.wrap("$100"))
end
end
+3
View File
@@ -13,4 +13,7 @@
# These inflection rules are supported but not enabled by default:
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "SQLite"
inflect.singular "quotas", "quota"
inflect.plural "quota", "quotas"
end
+24 -76
View File
@@ -2,7 +2,7 @@ require "test_helper"
class Ai::QuotaTest < ActiveSupport::TestCase
setup do
@quota = Ai::Quota.new(user: users(:jz), limit: "$100")
@quota = Ai::Quota.new(user: users(:jz), limit: Ai::Quota::Money.wrap("$100").in_microcents)
end
test "create" do
@@ -10,106 +10,54 @@ class Ai::QuotaTest < ActiveSupport::TestCase
assert_in_delta 7.days.from_now, @quota.reset_at, 1.minute
assert_equal 0, @quota.used
assert_equal Ai::Quota::Money.wrap("$100"), @quota.limit
end
test "accessors" do
@quota.limit = nil
assert_nil @quota.limit
@quota.limit = "$100"
assert_kind_of Ai::Quota::Money, @quota.limit
assert_equal Ai::Quota::Money.wrap("$100"), @quota.limit
@quota.used = nil
assert_nil @quota.used
@quota.used = "$100"
assert_kind_of Ai::Quota::Money, @quota.used
assert_equal Ai::Quota::Money.wrap("$100"), @quota.used
assert_equal Ai::Quota::Money.wrap("$100").in_microcents, @quota.limit
end
test "increment usage" do
@quota.save
@quota.increment_usage("$100")
assert_equal Ai::Quota::Money.wrap("$100"), @quota.used
@quota.increment_usage("$500")
assert_equal Ai::Quota::Money.wrap("$600"), @quota.used
@quota.increment_usage("$1000")
assert_equal Ai::Quota::Money.wrap("$1600"), @quota.used
@quota.increment_usage("$5000")
assert_equal Ai::Quota::Money.wrap("$6600"), @quota.used
@quota.increment_usage("$10000")
assert_equal Ai::Quota::Money.wrap("$16600"), @quota.used
@quota.spend("$100")
assert_equal Ai::Quota::Money.wrap("$100").in_microcents, @quota.used
@quota.spend("$500")
assert_equal Ai::Quota::Money.wrap("$600").in_microcents, @quota.used
@quota.spend("$1000")
assert_equal Ai::Quota::Money.wrap("$1600").in_microcents, @quota.used
@quota.spend("$5000")
assert_equal Ai::Quota::Money.wrap("$6600").in_microcents, @quota.used
@quota.spend("$10000")
assert_equal Ai::Quota::Money.wrap("$16600").in_microcents, @quota.used
@quota.reset
assert_equal Ai::Quota::Money.wrap("$0"), @quota.used
assert_in_delta 7.days.from_now, @quota.reset_at, 1.minute
@quota.used = 0
@quota.increment_usage("$10")
assert_equal Ai::Quota::Money.wrap("$10"), @quota.used
@quota.spend("$10")
assert_equal Ai::Quota::Money.wrap("$10").in_microcents, @quota.used
assert_in_delta 7.days.from_now, @quota.reset_at, 1.minute
travel 2.days
@quota.increment_usage("$5")
assert_equal Ai::Quota::Money.wrap("$15"), @quota.used
@quota.spend("$5")
assert_equal Ai::Quota::Money.wrap("$15").in_microcents, @quota.used
assert_in_delta 5.days.from_now, @quota.reset_at, 1.minute
travel 8.days
@quota.increment_usage("$5")
assert_equal Ai::Quota::Money.wrap("$5"), @quota.used
@quota.spend("$5")
assert_equal Ai::Quota::Money.wrap("$5").in_microcents, @quota.used
assert_in_delta 7.days.from_now, @quota.reset_at, 1.minute
end
test "limit checks" do
@quota.save
@quota.used = "$0"
assert_not @quota.over_limit?
@quota.used = 0
@quota.ensure_not_depleted
@quota.used = "$300"
assert @quota.over_limit?
@quota.used = "$0"
@quota.ensure_under_limit
@quota.used = "$300"
@quota.used = Ai::Quota::Money.wrap("$300").in_microcents
assert_raises Ai::Quota::UsageExceedsQuotaError do
@quota.ensure_under_limit
@quota.ensure_not_depleted
end
travel 10.days
@quota.ensure_under_limit
end
test "reset" do
@quota.used = "$15"
@quota.reset_at = 3.days.from_now
@quota.reset
assert_equal Ai::Quota::Money.wrap("$0"), @quota.used
assert_in_delta 7.days.from_now, @quota.reset_at, 1.minute
@quota.reset_at = 10.minutes.from_now
assert_not @quota.due_for_reset?
@quota.reset_at = 10.minutes.ago
assert @quota.due_for_reset?
@quota.reset_at = 10.minutes.from_now
@quota.used = "$15"
@quota.reset_if_due
assert_equal Ai::Quota::Money.wrap("$15"), @quota.used
assert_in_delta 10.minutes.from_now, @quota.reset_at, 1.minute
@quota.reset_at = 10.minutes.ago
@quota.used = "$15"
@quota.reset_if_due
assert_equal Ai::Quota::Money.wrap("$0"), @quota.used
assert_in_delta 7.days.from_now, @quota.reset_at, 1.minute
@quota.ensure_not_depleted
end
end