siGit Code is local-first and free; the cloud tiers (siGit Code Cloud)
now require a paid plan. This is siGit's own billing, separate from Onde
Inference's — siGit pays Onde as a customer; here it charges end users.
Entitlement + gate:
- Subscription model (free/pro/team, Stripe-cached) + migration
- User#entitled_to_cloud?; CloudCatalog classifies cloud vs on-device ids
- ChatCompletionsController gates cloud tiers behind an active plan (402),
before any SSE starts; on-device GGUF models stay free
Stripe (siGit's own):
- StripeService: checkout, billing portal, signed webhook parsing,
subscription sync (Stripe is source of truth, the table is a cache)
- Api::V1::BillingController (show/checkout/portal), StripeWebhooksController
- routes, initializer, stripe gem
Verified against test-mode Stripe: checkout session creation, webhook
signature + 200, and subscription->entitlement sync.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seto Elkahfi committedJun 23, 2026 at 22:05 UTC3ae4971340c96e396d4052da550139e36e69bdf9
14 files changed+342-1
.agents/skills/sigit-code-cloud/SKILL.md
+22
index ec13fbe..a9c1bb6 100644--- a/.agents/skills/sigit-code-cloud/SKILL.md+++ b/.agents/skills/sigit-code-cloud/SKILL.md@@ -179,6 +179,28 @@ while testing to keep upstream spend low. - **ACP path:** the Zed/ACP server in `sigit/src/main.rs` still calls `onde` directly; route it through `InferenceBackend` too.+## Billing — siGit's own, gating siGit Code Cloud++siGit Code is local-first and free; **siGit Code Cloud (the Fast/Balanced/Large+tiers) requires a paid plan.** This is siGit's *own* Stripe billing, separate from+Onde Inference's — siGit pays Onde as a customer; here siGit charges its end users.++- **Gate:** `Api::V1::ChatCompletionsController#enforce_cloud_entitlement!` —+ `CloudCatalog.cloud_tier?(model)` (the `onde-*`/`claude-*` ids) requires+ `current_user.entitled_to_cloud?`, else **402**. On-device GGUF ids pass free.+- **Entitlement:** `User#entitled_to_cloud?` → `Subscription#entitled_to_cloud?`+ (`pro`/`team` && `active`/`trialing`). No subscription = Free = local only.+- **Stripe:** `StripeService` (checkout/portal/`construct_event`/`sync_subscription`),+ `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.+ ## Common mistakes - Making BYO-endpoint (env / `providers.toml`) the default instead of the
Gemfile
+3
index 446f69f..47b3d71 100644--- a/Gemfile+++ b/Gemfile@@ -19,6 +19,9 @@ gem "tailwindcss-rails", "~> 3.3.1" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder"+# Stripe — siGit's own billing (siGit Code Pro/Team). Separate from Onde's billing.+gem "stripe", "~> 13"+ # smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout. gem "smbcloud-auth", "~> 0.4.5"
index 47b12a4..9148e39 100644--- a/app/controllers/api/v1/chat_completions_controller.rb+++ b/app/controllers/api/v1/chat_completions_controller.rb@@ -20,6 +20,7 @@ module Api include ActionController::Live before_action :authenticate_token!+ before_action :enforce_cloud_entitlement! def create if streaming_requested?@@ -33,6 +34,19 @@ module Api private+ # Gate the paid cloud tiers behind a siGit Code Pro subscription. On-device+ # models are always free and pass through. Runs before any streaming starts,+ # so a gated request gets a normal 402 JSON body, not a half-open SSE stream.+ def enforce_cloud_entitlement!+ model = completion_payload["model"]+ return unless CloudCatalog.cloud_tier?(model)+ return if current_user&.entitled_to_cloud?++ render json: error_body(+ "siGit Code Cloud requires a Pro subscription. Upgrade in your siGit account to use cloud models."+ ), status: :payment_required+ end+ # Pipe Onde Cloud's SSE response straight through to the client. def stream_completion response.headers["Content-Type"] = "text/event-stream"
app/controllers/stripe_webhooks_controller.rb
+45
new file mode 100644index 0000000..4c82674--- /dev/null+++ b/app/controllers/stripe_webhooks_controller.rb@@ -0,0 +1,45 @@+# frozen_string_literal: true++# Receives Stripe webhooks for siGit Code billing and syncs the local+# Subscription cache. Public endpoint, authenticated by the Stripe signature —+# never a user token.+class StripeWebhooksController < ActionController::API+ def create+ payload = request.body.read+ signature = request.headers["Stripe-Signature"]++ event =+ begin+ StripeService.construct_event(payload, signature)+ rescue JSON::ParserError, Stripe::SignatureVerificationError => e+ Rails.logger.warn("Stripe webhook rejected: #{e.class}: #{e.message}")+ return head :bad_request+ rescue KeyError+ Rails.logger.error("Stripe webhook secret not configured")+ return head :service_unavailable+ end++ handle(event)+ head :ok+ rescue StandardError => e+ Rails.logger.error("Stripe webhook handling failed: #{e.message}")+ head :ok # ack so Stripe doesn't retry a poison event forever; we logged it+ end++ private++ def handle(event)+ case event.type+ when "checkout.session.completed"+ session = event.data.object+ subscription_id = session.subscription+ StripeService.sync_subscription(Stripe::Subscription.retrieve(subscription_id)) if subscription_id+ when "customer.subscription.created",+ "customer.subscription.updated",+ "customer.subscription.deleted"+ StripeService.sync_subscription(event.data.object)+ else+ Rails.logger.debug("Stripe webhook ignored: #{event.type}")+ end+ end+end
app/models/subscription.rb
+16
new file mode 100644index 0000000..efe244b--- /dev/null+++ b/app/models/subscription.rb@@ -0,0 +1,16 @@+# frozen_string_literal: true++# A user's siGit plan + Stripe subscription state. Source of truth is Stripe;+# this caches what the cloud gate needs. A user with no Subscription row is+# treated as Free (local-only, no cloud).+class Subscription < ApplicationRecord+ belongs_to :user++ enum :plan, { free: 0, pro: 1, team: 2 }+ enum :status, { active: 0, past_due: 1, canceled: 2, trialing: 3, incomplete: 4 }++ # Entitled to siGit Code Cloud: on a paid plan in good standing.+ def entitled_to_cloud?+ (pro? || team?) && (active? || trialing?)+ end+end
app/models/user.rb
+7
index a8c2fd1..3a42ee8 100644--- a/app/models/user.rb+++ b/app/models/user.rb@@ -5,6 +5,13 @@ class User < ApplicationRecord has_many :ssh_keys, dependent: :destroy has_many :stars, dependent: :destroy has_many :starred_repositories, through: :stars, source: :repository+ has_one :subscription, dependent: :destroy++ # Whether this user may use siGit Code Cloud (the paid cloud tiers). Free /+ # unsubscribed users run on-device only.+ def entitled_to_cloud?+ subscription&.entitled_to_cloud? || false+ end validates :smbcloud_id, presence: true,
app/services/cloud_catalog.rb
+16
new file mode 100644index 0000000..1205f06--- /dev/null+++ b/app/services/cloud_catalog.rb@@ -0,0 +1,16 @@+# frozen_string_literal: true++# Classifies a requested model as a paid siGit Code Cloud tier vs a free+# on-device model. The neutral cloud tiers (Fast/Balanced/Large) and the legacy+# `claude-*` / `anthropic/*` aliases run on Onde Cloud and cost money; everything+# else (GGUF `owner/repo/file` ids) runs on the user's own device and is free.+class CloudCatalog+ CLOUD_TIERS = %w[onde-fast onde-balanced onde-large].freeze++ def self.cloud_tier?(model)+ id = model.to_s.strip.downcase+ return false if id.empty?++ CLOUD_TIERS.include?(id) || id.start_with?("claude-", "anthropic/")+ end+end
app/services/stripe_service.rb
+105
new file mode 100644index 0000000..7e84de8--- /dev/null+++ b/app/services/stripe_service.rb@@ -0,0 +1,105 @@+# frozen_string_literal: true++# siGit's Stripe integration for siGit Code Pro/Team. Stripe is the source of+# truth; we cache plan + status into Subscription so the cloud gate decides+# without a Stripe round-trip. Entirely separate from Onde Inference's billing.+class StripeService+ # Our plan → the Stripe price lookup_key (prices created in Stripe).+ PRICE_LOOKUP_KEYS = {+ "pro" => "sigit_code_pro_monthly",+ "team" => "sigit_code_team_monthly"+ }.freeze++ # A subscription's price lookup_key → our plan.+ PLAN_BY_LOOKUP_KEY = {+ "sigit_code_pro_monthly" => :pro,+ "sigit_code_team_monthly" => :team+ }.freeze++ # Stripe subscription status → our Subscription status enum.+ STATUS_MAP = {+ "active" => :active,+ "trialing" => :trialing,+ "past_due" => :past_due,+ "unpaid" => :past_due,+ "canceled" => :canceled,+ "incomplete_expired" => :canceled,+ "incomplete" => :incomplete,+ "paused" => :incomplete+ }.freeze++ class << self+ def configured?+ ENV["STRIPE_SECRET_KEY"].present?+ end++ # Create a Checkout session for a user + plan; returns the redirect URL.+ def checkout_url(user:, plan:, success_url:, cancel_url:)+ price_id = price_id_for(plan)+ raise ArgumentError, "Unknown plan #{plan}" unless price_id++ existing_customer = user.subscription&.stripe_customer_id++ session = Stripe::Checkout::Session.create({+ mode: "subscription",+ line_items: [{ price: price_id, quantity: 1 }],+ client_reference_id: user.id.to_s,+ customer: existing_customer,+ customer_email: existing_customer ? nil : user.email,+ allow_promotion_codes: true,+ success_url: success_url,+ cancel_url: cancel_url,+ metadata: { user_id: user.id.to_s },+ subscription_data: { metadata: { user_id: user.id.to_s } }+ }.compact)++ session.url+ end++ # Create a Billing Portal session for a user; returns the URL. nil when the+ # user has no Stripe customer yet (never subscribed).+ def portal_url(user:, return_url:)+ customer = user.subscription&.stripe_customer_id+ return nil unless customer++ Stripe::BillingPortal::Session.create(customer: customer, return_url: return_url).url+ end++ # Verify + parse a webhook payload into a Stripe::Event.+ def construct_event(payload, signature_header)+ Stripe::Webhook.construct_event(payload, signature_header, ENV.fetch("STRIPE_WEBHOOK_SECRET"))+ end++ # Upsert the local Subscription from a Stripe subscription object. Returns the+ # Subscription, or nil when the subscription isn't attributable to a user.+ def sync_subscription(stripe_subscription)+ user_id = stripe_subscription.metadata && stripe_subscription.metadata["user_id"]+ user = User.find_by(id: user_id)+ return nil unless user++ price = stripe_subscription.items&.data&.first&.price+ plan = PLAN_BY_LOOKUP_KEY[price&.lookup_key] || :free+ status = STATUS_MAP[stripe_subscription.status] || :incomplete+ period_end = stripe_subscription.current_period_end++ subscription = user.subscription || user.build_subscription+ subscription.update!(+ plan: plan,+ status: status,+ stripe_customer_id: stripe_subscription.customer,+ stripe_subscription_id: stripe_subscription.id,+ current_period_end: period_end ? Time.at(period_end) : nil+ )+ subscription+ end++ private++ def price_id_for(plan)+ lookup_key = PRICE_LOOKUP_KEYS[plan.to_s]+ return nil unless lookup_key++ Stripe::Price.list(lookup_keys: [lookup_key], active: true, limit: 1).data.first&.id+ end+ end+end
config/initializers/stripe.rb
+5
new file mode 100644index 0000000..7b5ee88--- /dev/null+++ b/config/initializers/stripe.rb@@ -0,0 +1,5 @@+# frozen_string_literal: true++# siGit's own Stripe API key (siGit Code Pro/Team). Separate product line from+# Onde Inference's billing, though it may share the Stripe account.+Stripe.api_key = ENV["STRIPE_SECRET_KEY"] if ENV["STRIPE_SECRET_KEY"].present?
config/routes.rb
+6
index 77c08e8..ff6473a 100644--- a/config/routes.rb+++ b/config/routes.rb@@ -72,9 +72,15 @@ Rails.application.routes.draw do post "repos", to: "repos#create" post "git_credentials", to: "git_credentials#create" post "chat/completions", to: "chat_completions#create"+ get "billing", to: "billing#show"+ post "billing/checkout", to: "billing#checkout"+ post "billing/portal", to: "billing#portal" end end+ # Stripe webhooks for siGit Code billing (authenticated by Stripe signature).+ post "/stripe/webhooks", to: "stripe_webhooks#create"+ # Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before # the catch-all "/:username" routes; the `*.git` constraint keeps them from # matching normal repo-browsing URLs.
db/migrate/20250101000005_create_subscriptions.rb
+24
new file mode 100644index 0000000..82558e1--- /dev/null+++ b/db/migrate/20250101000005_create_subscriptions.rb@@ -0,0 +1,24 @@+# frozen_string_literal: true++# A siGit user's subscription. siGit Code is local-first and free; a paid plan+# unlocks siGit Code Cloud (the Fast/Balanced/Large tiers). Stripe is siGit's own+# — separate from Onde Inference's billing — and is the source of truth; this row+# caches plan + status so the cloud gate can decide without a Stripe round-trip.+class CreateSubscriptions < ActiveRecord::Migration[8.1]+ def change+ create_table :subscriptions do |t|+ t.references :user, null: false, foreign_key: true, index: { unique: true }+ t.integer :plan, null: false, default: 0 # free=0, pro=1, team=2+ t.integer :status, null: false, default: 0 # active=0, past_due=1, canceled=2, trialing=3, incomplete=4++ t.string :stripe_customer_id+ t.string :stripe_subscription_id+ t.datetime :current_period_end++ t.timestamps+ end++ add_index :subscriptions, :stripe_customer_id+ add_index :subscriptions, :stripe_subscription_id+ end+end
db/schema.rb
+16-1
index 057d71f..ba12b84 100644--- a/db/schema.rb+++ b/db/schema.rb@@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system.-ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do+ActiveRecord::Schema[8.1].define(version: 2025_01_01_000005) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql"@@ -51,6 +51,20 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do t.index ["user_id"], name: "index_stars_on_user_id" end+ create_table "subscriptions", force: :cascade do |t|+ t.datetime "created_at", null: false+ t.datetime "current_period_end"+ t.integer "plan", default: 0, null: false+ t.integer "status", default: 0, null: false+ t.string "stripe_customer_id"+ t.string "stripe_subscription_id"+ t.datetime "updated_at", null: false+ t.bigint "user_id", null: false+ t.index ["stripe_customer_id"], name: "index_subscriptions_on_stripe_customer_id"+ t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id"+ t.index ["user_id"], name: "index_subscriptions_on_user_id", unique: true+ end+ create_table "users", force: :cascade do |t| t.string "access_token" t.string "avatar_url"@@ -69,4 +83,5 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do add_foreign_key "ssh_keys", "users" add_foreign_key "stars", "repositories" add_foreign_key "stars", "users"+ add_foreign_key "subscriptions", "users" end