Class: Clacky::Billing::BillingStore

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/billing/billing_store.rb

Overview

Persistent storage for billing records using JSONL files Records are stored in monthly files: ~/.clacky/billing/YYYY-MM.jsonl

Constant Summary collapse

BILLING_DIR =
File.join(Dir.home, ".clacky", "billing")

Instance Method Summary collapse

Constructor Details

#initialize(billing_dir: nil) ⇒ BillingStore

Returns a new instance of BillingStore.



15
16
17
18
# File 'lib/clacky/billing/billing_store.rb', line 15

def initialize(billing_dir: nil)
  @billing_dir = billing_dir || BILLING_DIR
  ensure_billing_dir
end

Instance Method Details

#append(record) ⇒ String

Append a billing record to the current month’s file

Parameters:

Returns:

  • (String)

    The record ID



23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/clacky/billing/billing_store.rb', line 23

def append(record)
  record.id ||= SecureRandom.uuid
  record.timestamp ||= Time.now

  month_file = current_month_file
  File.open(month_file, "a") do |f|
    f.puts(JSON.generate(record.to_h))
  end
  FileUtils.chmod(0o600, month_file)

  record.id
end

#cleanup(before:) ⇒ Integer

Delete old billing records

Parameters:

  • before (Time)

    Delete records before this time

Returns:

  • (Integer)

    Number of files deleted



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/clacky/billing/billing_store.rb', line 145

def cleanup(before:)
  deleted = 0
  billing_files.each do |file|
    # Parse month from filename (YYYY-MM.jsonl)
    basename = File.basename(file, ".jsonl")
    file_month = Time.parse("#{basename}-01") rescue nil
    next unless file_month

    # Delete if the entire month is before the cutoff
    if file_month < before - (31 * 24 * 60 * 60)
      File.delete(file)
      deleted += 1
    end
  end
  deleted
end

#daily_breakdown(days: 30) ⇒ Array<Hash>

Get daily cost breakdown for the last N days

Parameters:

  • days (Integer) (defaults to: 30)

    Number of days to include

Returns:

  • (Array<Hash>)

    Daily summaries with date and cost



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/clacky/billing/billing_store.rb', line 120

def daily_breakdown(days: 30)
  from_time = Time.now - (days * 24 * 60 * 60)
  records = query(from: from_time)

  by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }

  (0...days).map do |i|
    date = (Time.now - (i * 24 * 60 * 60)).strftime("%Y-%m-%d")
    day_records = by_day[date] || []
    {
      date: date,
      cost: day_records.sum { |r| r.cost_usd || 0 }.round(6),
      tokens: day_records.sum { |r| r.total_tokens },
      prompt_tokens: day_records.sum { |r| r.prompt_tokens || 0 },
      completion_tokens: day_records.sum { |r| r.completion_tokens || 0 },
      cache_read_tokens: day_records.sum { |r| r.cache_read_tokens || 0 },
      cache_write_tokens: day_records.sum { |r| r.cache_write_tokens || 0 },
      requests: day_records.size
    }
  end.reverse
end

#query(from: nil, to: nil, model: nil, session_id: nil, limit: nil) ⇒ Array<BillingRecord>

Query billing records with optional filters

Parameters:

  • from (Time, nil) (defaults to: nil)

    Start time (inclusive)

  • to (Time, nil) (defaults to: nil)

    End time (inclusive)

  • model (String, nil) (defaults to: nil)

    Filter by model name

  • session_id (String, nil) (defaults to: nil)

    Filter by session ID

  • limit (Integer, nil) (defaults to: nil)

    Maximum number of records to return

Returns:



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/clacky/billing/billing_store.rb', line 43

def query(from: nil, to: nil, model: nil, session_id: nil, limit: nil)
  records = []

  billing_files.each do |file|
    File.foreach(file) do |line|
      next if line.strip.empty?

      begin
        hash = JSON.parse(line, symbolize_names: true)
        record = BillingRecord.from_h(hash)

        # Apply filters
        next if from && record.timestamp < from
        next if to && record.timestamp > to
        next if model && record.model != model
        next if session_id && record.session_id != session_id

        records << record
      rescue JSON::ParserError
        # Skip malformed lines
        next
      end
    end
  end

  # Sort by timestamp descending (newest first)
  records.sort_by! { |r| r.timestamp }.reverse!

  # Apply limit
  limit ? records.first(limit) : records
end

#summary(period: :month) ⇒ Hash

Get summary statistics for a time period

Parameters:

  • period (Symbol) (defaults to: :month)

    :day, :week, :month, :year, or :all

Returns:

  • (Hash)

    Summary with total_cost, total_tokens, by_model, etc.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/clacky/billing/billing_store.rb', line 78

def summary(period: :month)
  from_time = period_start(period)
  records = query(from: from_time)

  total_cost = records.sum { |r| r.cost_usd || 0 }
  total_prompt = records.sum { |r| r.prompt_tokens || 0 }
  total_completion = records.sum { |r| r.completion_tokens || 0 }
  total_cache_read = records.sum { |r| r.cache_read_tokens || 0 }
  total_cache_write = records.sum { |r| r.cache_write_tokens || 0 }

  by_model = records.group_by(&:model).transform_values do |rs|
    {
      cost: rs.sum { |r| r.cost_usd || 0 },
      prompt_tokens: rs.sum { |r| r.prompt_tokens || 0 },
      completion_tokens: rs.sum { |r| r.completion_tokens || 0 },
      requests: rs.size
    }
  end

  by_day = records.group_by { |r| r.timestamp.strftime("%Y-%m-%d") }.transform_values do |rs|
    rs.sum { |r| r.cost_usd || 0 }
  end

  {
    period: period,
    from: from_time&.iso8601,
    to: Time.now.iso8601,
    total_cost: total_cost.round(6),
    total_tokens: total_prompt + total_completion,
    prompt_tokens: total_prompt,
    completion_tokens: total_completion,
    cache_read_tokens: total_cache_read,
    cache_write_tokens: total_cache_write,
    by_model: by_model,
    by_day: by_day,
    record_count: records.size
  }
end