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
index a9c1bb6..bdad5a6 100644
--- a/.agents/skills/sigit-code-cloud/SKILL.md
+++ b/.agents/skills/sigit-code-cloud/SKILL.md
@@ -194,12 +194,17 @@ Onde Inference's — siGit pays Onde as a customer; here siGit charges its end u
`Api::V1::BillingController` (`GET billing`, `POST billing/checkout|portal`),
`StripeWebhooksController` (`POST /stripe/webhooks`, signature-verified). Stripe is
the source of truth; the `subscriptions` table caches plan + status.
-- **Plans (test mode):** product `siGit Code`; prices `sigit_code_pro_monthly`
- ($20/mo) and `sigit_code_team_monthly` ($40/mo), resolved by lookup key.
-- **Env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. Checkout is **web-initiated**
- (desktop/CLI open the returned URL) to avoid the App Store cut.
-- **Remaining:** per-plan monthly cloud **allowance metering** (enforce in the same
- gate using the `usage` Onde Cloud now returns), and a web/desktop billing UI.
+- **Allowance:** `enforce_cloud_allowance!` caps cloud requests per billing period
+ (`Subscription::CLOUD_ALLOWANCE` — pro 2000, team 6000; tunable). `CloudUsage`
+ counts per `(user, billing_period_key)`, so it resets each cycle; over the cap →
+ **429**. `GET /api/v1/billing` returns `cloud_requests_used` + `cloud_allowance`.
+- **Plans:** product `siGit Code` in **siGit's own Stripe account**
+ (`acct_1TlaO9LwK8mGvn31`, Splitfire AB); prices `sigit_code_pro_monthly` ($20/mo)
+ and `sigit_code_team_monthly` ($40/mo), resolved by lookup key.
+- **Env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (siGit account). Checkout is
+ **web-initiated** (desktop/CLI open the returned URL) to avoid the App Store cut.
+- **Remaining:** a web/desktop billing UI; metering by tokens (the `usage` Onde
+ Cloud returns) instead of request count, if finer cost control is needed.
## Common mistakes
app/controllers/api/v1/billing_controller.rb
+2
index 8391a0b..a845c15 100644
--- a/app/controllers/api/v1/billing_controller.rb
+++ b/app/controllers/api/v1/billing_controller.rb
@@ -16,6 +16,8 @@ module Api
plan: subscription&.plan || "free",
status: subscription&.status || "active",
entitled_to_cloud: current_user.entitled_to_cloud?,
+ cloud_requests_used: current_user.cloud_requests_used,
+ cloud_allowance: current_user.cloud_allowance,
current_period_end: subscription&.current_period_end
}, status: :ok
end
app/controllers/api/v1/chat_completions_controller.rb
+17
index 9148e39..2e64c1f 100644
--- a/app/controllers/api/v1/chat_completions_controller.rb
+++ b/app/controllers/api/v1/chat_completions_controller.rb
@@ -21,6 +21,7 @@ module Api
before_action :authenticate_token!
before_action :enforce_cloud_entitlement!
+ before_action :enforce_cloud_allowance!
def create
if streaming_requested?
@@ -47,6 +48,22 @@ module Api
), status: :payment_required
end
+ # Cap cloud usage at the plan's monthly allowance. Only cloud tiers count;
+ # on-device is free and unmetered. Runs after entitlement, so it only sees
+ # paying users. Records the request when allowed.
+ def enforce_cloud_allowance!
+ return unless CloudCatalog.cloud_tier?(completion_payload["model"])
+ return unless current_user&.entitled_to_cloud?
+
+ unless current_user.cloud_allowance_available?
+ return render json: error_body(
+ "Monthly siGit Code Cloud allowance reached. It resets at the start of your next billing period."
+ ), status: :too_many_requests
+ end
+
+ current_user.record_cloud_request!
+ end
+
# Pipe Onde Cloud's SSE response straight through to the client.
def stream_completion
response.headers["Content-Type"] = "text/event-stream"
app/models/cloud_usage.rb
+21
new file mode 100644
index 0000000..0ff7d28
--- /dev/null
+++ b/app/models/cloud_usage.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# One row per user per billing period, counting siGit Code Cloud requests for
+# allowance enforcement. Atomic increments so concurrent requests can't lose a
+# count.
+class CloudUsage < ApplicationRecord
+ belongs_to :user
+
+ # Count for a user in the given period (0 when unseen).
+ def self.used(user, period_key)
+ where(user_id: user.id, period_key: period_key).pick(:request_count) || 0
+ end
+
+ # Record one cloud request against the user's current period. Returns the new
+ # count.
+ def self.record_request!(user, period_key)
+ row = find_or_create_by!(user_id: user.id, period_key: period_key)
+ where(id: row.id).update_all("request_count = request_count + 1, updated_at = NOW()")
+ row.reload.request_count
+ end
+end
app/models/subscription.rb
+15
index efe244b..120b5db 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -9,8 +9,23 @@ class Subscription < ApplicationRecord
enum :plan, { free: 0, pro: 1, team: 2 }
enum :status, { active: 0, past_due: 1, canceled: 2, trialing: 3, incomplete: 4 }
+ # Monthly siGit Code Cloud request allowance per plan. Tunable pricing knobs —
+ # these are the included cloud requests before the cap kicks in.
+ CLOUD_ALLOWANCE = { "pro" => 2_000, "team" => 6_000 }.freeze
+
# Entitled to siGit Code Cloud: on a paid plan in good standing.
def entitled_to_cloud?
(pro? || team?) && (active? || trialing?)
end
+
+ # Included cloud requests for this plan's billing period.
+ def cloud_allowance
+ CLOUD_ALLOWANCE.fetch(plan, 0)
+ end
+
+ # Identifies the current billing period so usage resets each cycle. Uses the
+ # Stripe period end when known; falls back to the calendar month.
+ def billing_period_key
+ current_period_end&.utc&.to_date&.iso8601 || Time.current.utc.strftime("%Y-%m")
+ end
end
app/models/user.rb
+24
index 3a42ee8..ad71ec4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -6,6 +6,7 @@ class User < ApplicationRecord
has_many :stars, dependent: :destroy
has_many :starred_repositories, through: :stars, source: :repository
has_one :subscription, dependent: :destroy
+ has_many :cloud_usages, dependent: :destroy
# Whether this user may use siGit Code Cloud (the paid cloud tiers). Free /
# unsubscribed users run on-device only.
@@ -13,6 +14,29 @@ class User < ApplicationRecord
subscription&.entitled_to_cloud? || false
end
+ # Cloud requests used / allowed in the current billing period.
+ def cloud_requests_used
+ return 0 unless subscription
+
+ CloudUsage.used(self, subscription.billing_period_key)
+ end
+
+ def cloud_allowance
+ subscription&.cloud_allowance || 0
+ end
+
+ # True while the user still has cloud allowance left this period.
+ def cloud_allowance_available?
+ cloud_requests_used < cloud_allowance
+ end
+
+ # Count one cloud request against this period. No-op without a subscription.
+ def record_cloud_request!
+ return unless subscription
+
+ CloudUsage.record_request!(self, subscription.billing_period_key)
+ end
+
validates :smbcloud_id,
presence: true,
uniqueness: true,
db/migrate/20250101000006_create_cloud_usages.rb
+19
new file mode 100644
index 0000000..712ed30
--- /dev/null
+++ b/db/migrate/20250101000006_create_cloud_usages.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# Per-user, per-billing-period count of siGit Code Cloud requests, used to enforce
+# the plan's monthly allowance. `period_key` is the subscription's current-period
+# marker, so the count resets automatically each billing cycle (a new period →
+# a new row). On-device usage is never recorded here — it's free and unmetered.
+class CreateCloudUsages < ActiveRecord::Migration[8.1]
+ def change
+ create_table :cloud_usages do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :period_key, null: false
+ t.integer :request_count, null: false, default: 0
+
+ t.timestamps
+ end
+
+ add_index :cloud_usages, %i[user_id period_key], unique: true
+ end
+end
db/schema.rb
+12
-1
index ba12b84..f0538ec 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,10 +10,20 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_01_01_000005) do
+ActiveRecord::Schema[8.1].define(version: 2025_01_01_000006) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
+ create_table "cloud_usages", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "period_key", null: false
+ t.integer "request_count", default: 0, null: false
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.index ["user_id", "period_key"], name: "index_cloud_usages_on_user_id_and_period_key", unique: true
+ t.index ["user_id"], name: "index_cloud_usages_on_user_id"
+ end
+
create_table "repositories", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "default_branch", default: "main", null: false
@@ -79,6 +89,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000005) do
t.index ["username"], name: "index_users_on_username", unique: true
end
+ add_foreign_key "cloud_usages", "users"
add_foreign_key "repositories", "users"
add_foreign_key "ssh_keys", "users"
add_foreign_key "stars", "repositories"