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"