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"