Add siGit Code Cloud allowance metering
Cap cloud requests per billing period so heavy use can't run up an unbounded Onde Cloud bill. Enforced in the same gate; on-device stays free and unmetered. - CloudUsage table + model: atomic per-(user, billing_period_key) counter - Subscription::CLOUD_ALLOWANCE (pro 2000, team 6000; tunable) + billing_period_key so usage resets each cycle - enforce_cloud_allowance!: 429 over the cap, records the request otherwise - GET /api/v1/billing returns cloud_requests_used + cloud_allowance Verified: counting, capping at the limit, and per-period reset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seto Elkahfi committed
Jun 23, 2026 at 22:24 UTC
9e7edbef61206602c7340d8947c309b968d0cef6
8 files changed
+121
-7
.agents/skills/sigit-code-cloud/SKILL.md
+11
-6
@@ -194,12 +194,17 @@ Onde Inference's — siGit pays Onde as a customer; here siGit charges its end u
194
`Api::V1::BillingController` (`GET billing`, `POST billing/checkout|portal`),
195
`StripeWebhooksController` (`POST /stripe/webhooks`, signature-verified). Stripe is
196
the source of truth; the `subscriptions` table caches plan + status.
197
-- **Plans (test mode):** product `siGit Code`; prices `sigit_code_pro_monthly`
198
- ($20/mo) and `sigit_code_team_monthly` ($40/mo), resolved by lookup key.
199
-- **Env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. Checkout is **web-initiated**
200
- (desktop/CLI open the returned URL) to avoid the App Store cut.
201
-- **Remaining:** per-plan monthly cloud **allowance metering** (enforce in the same
202
- gate using the `usage` Onde Cloud now returns), and a web/desktop billing UI.
197
+- **Allowance:** `enforce_cloud_allowance!` caps cloud requests per billing period
198
+ (`Subscription::CLOUD_ALLOWANCE` — pro 2000, team 6000; tunable). `CloudUsage`
199
+ counts per `(user, billing_period_key)`, so it resets each cycle; over the cap →
200
+ **429**. `GET /api/v1/billing` returns `cloud_requests_used` + `cloud_allowance`.
201
+- **Plans:** product `siGit Code` in **siGit's own Stripe account**
202
+ (`acct_1TlaO9LwK8mGvn31`, Splitfire AB); prices `sigit_code_pro_monthly` ($20/mo)
203
+ and `sigit_code_team_monthly` ($40/mo), resolved by lookup key.
204
+- **Env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (siGit account). Checkout is
205
+ **web-initiated** (desktop/CLI open the returned URL) to avoid the App Store cut.
206
+- **Remaining:** a web/desktop billing UI; metering by tokens (the `usage` Onde
207
+ Cloud returns) instead of request count, if finer cost control is needed.
208
209
## Common mistakes
210
app/controllers/api/v1/billing_controller.rb
+2
@@ -16,6 +16,8 @@ module Api
16
plan: subscription&.plan || "free",
17
status: subscription&.status || "active",
18
entitled_to_cloud: current_user.entitled_to_cloud?,
19
+ cloud_requests_used: current_user.cloud_requests_used,
20
+ cloud_allowance: current_user.cloud_allowance,
21
current_period_end: subscription&.current_period_end
22
}, status: :ok
23
end
app/controllers/api/v1/chat_completions_controller.rb
+17
@@ -21,6 +21,7 @@ module Api
21
22
before_action :authenticate_token!
23
before_action :enforce_cloud_entitlement!
24
+ before_action :enforce_cloud_allowance!
25
26
def create
27
if streaming_requested?
@@ -47,6 +48,22 @@ module Api
48
), status: :payment_required
49
end
50
51
+ # Cap cloud usage at the plan's monthly allowance. Only cloud tiers count;
52
+ # on-device is free and unmetered. Runs after entitlement, so it only sees
53
+ # paying users. Records the request when allowed.
54
+ def enforce_cloud_allowance!
55
+ return unless CloudCatalog.cloud_tier?(completion_payload["model"])
56
+ return unless current_user&.entitled_to_cloud?
57
+
58
+ unless current_user.cloud_allowance_available?
59
+ return render json: error_body(
60
+ "Monthly siGit Code Cloud allowance reached. It resets at the start of your next billing period."
61
+ ), status: :too_many_requests
62
+ end
63
+
64
+ current_user.record_cloud_request!
65
+ end
66
+
67
# Pipe Onde Cloud's SSE response straight through to the client.
68
def stream_completion
69
response.headers["Content-Type"] = "text/event-stream"
app/models/cloud_usage.rb
new
+21
@@ -0,0 +1,21 @@
1
+# frozen_string_literal: true
2
+
3
+# One row per user per billing period, counting siGit Code Cloud requests for
4
+# allowance enforcement. Atomic increments so concurrent requests can't lose a
5
+# count.
6
+class CloudUsage < ApplicationRecord
7
+ belongs_to :user
8
+
9
+ # Count for a user in the given period (0 when unseen).
10
+ def self.used(user, period_key)
11
+ where(user_id: user.id, period_key: period_key).pick(:request_count) || 0
12
+ end
13
+
14
+ # Record one cloud request against the user's current period. Returns the new
15
+ # count.
16
+ def self.record_request!(user, period_key)
17
+ row = find_or_create_by!(user_id: user.id, period_key: period_key)
18
+ where(id: row.id).update_all("request_count = request_count + 1, updated_at = NOW()")
19
+ row.reload.request_count
20
+ end
21
+end
app/models/subscription.rb
+15
@@ -9,8 +9,23 @@ class Subscription < ApplicationRecord
9
enum :plan, { free: 0, pro: 1, team: 2 }
10
enum :status, { active: 0, past_due: 1, canceled: 2, trialing: 3, incomplete: 4 }
11
12
+ # Monthly siGit Code Cloud request allowance per plan. Tunable pricing knobs —
13
+ # these are the included cloud requests before the cap kicks in.
14
+ CLOUD_ALLOWANCE = { "pro" => 2_000, "team" => 6_000 }.freeze
15
+
16
# Entitled to siGit Code Cloud: on a paid plan in good standing.
17
def entitled_to_cloud?
18
(pro? || team?) && (active? || trialing?)
19
end
20
+
21
+ # Included cloud requests for this plan's billing period.
22
+ def cloud_allowance
23
+ CLOUD_ALLOWANCE.fetch(plan, 0)
24
+ end
25
+
26
+ # Identifies the current billing period so usage resets each cycle. Uses the
27
+ # Stripe period end when known; falls back to the calendar month.
28
+ def billing_period_key
29
+ current_period_end&.utc&.to_date&.iso8601 || Time.current.utc.strftime("%Y-%m")
30
+ end
31
end
app/models/user.rb
+24
@@ -6,6 +6,7 @@ class User < ApplicationRecord
6
has_many :stars, dependent: :destroy
7
has_many :starred_repositories, through: :stars, source: :repository
8
has_one :subscription, dependent: :destroy
9
+ has_many :cloud_usages, dependent: :destroy
10
11
# Whether this user may use siGit Code Cloud (the paid cloud tiers). Free /
12
# unsubscribed users run on-device only.
@@ -13,6 +14,29 @@ class User < ApplicationRecord
14
subscription&.entitled_to_cloud? || false
15
end
16
17
+ # Cloud requests used / allowed in the current billing period.
18
+ def cloud_requests_used
19
+ return 0 unless subscription
20
+
21
+ CloudUsage.used(self, subscription.billing_period_key)
22
+ end
23
+
24
+ def cloud_allowance
25
+ subscription&.cloud_allowance || 0
26
+ end
27
+
28
+ # True while the user still has cloud allowance left this period.
29
+ def cloud_allowance_available?
30
+ cloud_requests_used < cloud_allowance
31
+ end
32
+
33
+ # Count one cloud request against this period. No-op without a subscription.
34
+ def record_cloud_request!
35
+ return unless subscription
36
+
37
+ CloudUsage.record_request!(self, subscription.billing_period_key)
38
+ end
39
+
40
validates :smbcloud_id,
41
presence: true,
42
uniqueness: true,
db/migrate/20250101000006_create_cloud_usages.rb
new
+19
@@ -0,0 +1,19 @@
1
+# frozen_string_literal: true
2
+
3
+# Per-user, per-billing-period count of siGit Code Cloud requests, used to enforce
4
+# the plan's monthly allowance. `period_key` is the subscription's current-period
5
+# marker, so the count resets automatically each billing cycle (a new period →
6
+# a new row). On-device usage is never recorded here — it's free and unmetered.
7
+class CreateCloudUsages < ActiveRecord::Migration[8.1]
8
+ def change
9
+ create_table :cloud_usages do |t|
10
+ t.references :user, null: false, foreign_key: true
11
+ t.string :period_key, null: false
12
+ t.integer :request_count, null: false, default: 0
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :cloud_usages, %i[user_id period_key], unique: true
18
+ end
19
+end
db/schema.rb
+12
-1
@@ -10,10 +10,20 @@
10
#
11
# It's strongly recommended that you check this file into your version control system.
12
13
-ActiveRecord::Schema[8.1].define(version: 2025_01_01_000005) do
13
+ActiveRecord::Schema[8.1].define(version: 2025_01_01_000006) do
14
# These are extensions that must be enabled in order to support this database
15
enable_extension "pg_catalog.plpgsql"
16
17
+ create_table "cloud_usages", force: :cascade do |t|
18
+ t.datetime "created_at", null: false
19
+ t.string "period_key", null: false
20
+ t.integer "request_count", default: 0, null: false
21
+ t.datetime "updated_at", null: false
22
+ t.bigint "user_id", null: false
23
+ t.index ["user_id", "period_key"], name: "index_cloud_usages_on_user_id_and_period_key", unique: true
24
+ t.index ["user_id"], name: "index_cloud_usages_on_user_id"
25
+ end
26
+
27
create_table "repositories", force: :cascade do |t|
28
t.datetime "created_at", null: false
29
t.string "default_branch", default: "main", null: false
@@ -79,6 +89,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000005) do
89
t.index ["username"], name: "index_users_on_username", unique: true
90
end
91
92
+ add_foreign_key "cloud_usages", "users"
93
add_foreign_key "repositories", "users"
94
add_foreign_key "ssh_keys", "users"
95
add_foreign_key "stars", "repositories"