Add siGit Code Cloud billing: entitlement gate + Stripe

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 committed Jun 23, 2026 at 22:05 UTC 3ae4971340c96e396d4052da550139e36e69bdf9
14 files changed +342 -1
.agents/skills/sigit-code-cloud/SKILL.md
+22
@@ -179,6 +179,28 @@ while testing to keep upstream spend low.
179 - **ACP path:** the Zed/ACP server in `sigit/src/main.rs` still calls `onde`
180 directly; route it through `InferenceBackend` too.
181
182 +## Billing — siGit's own, gating siGit Code Cloud
183 +
184 +siGit Code is local-first and free; **siGit Code Cloud (the Fast/Balanced/Large
185 +tiers) requires a paid plan.** This is siGit's *own* Stripe billing, separate from
186 +Onde Inference's — siGit pays Onde as a customer; here siGit charges its end users.
187 +
188 +- **Gate:** `Api::V1::ChatCompletionsController#enforce_cloud_entitlement!` —
189 + `CloudCatalog.cloud_tier?(model)` (the `onde-*`/`claude-*` ids) requires
190 + `current_user.entitled_to_cloud?`, else **402**. On-device GGUF ids pass free.
191 +- **Entitlement:** `User#entitled_to_cloud?` → `Subscription#entitled_to_cloud?`
192 + (`pro`/`team` && `active`/`trialing`). No subscription = Free = local only.
193 +- **Stripe:** `StripeService` (checkout/portal/`construct_event`/`sync_subscription`),
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.
203 +
204 ## Common mistakes
205
206 - Making BYO-endpoint (env / `providers.toml`) the default instead of the
Gemfile
+3
@@ -19,6 +19,9 @@ gem "tailwindcss-rails", "~> 3.3.1"
19 # Build JSON APIs with ease [https://github.com/rails/jbuilder]
20 gem "jbuilder"
21
22 +# Stripe — siGit's own billing (siGit Code Pro/Team). Separate from Onde's billing.
23 +gem "stripe", "~> 13"
24 +
25 # smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout.
26 gem "smbcloud-auth", "~> 0.4.5"
27
Gemfile.lock
+2
@@ -324,6 +324,7 @@ GEM
324 stimulus-rails (1.3.4)
325 railties (>= 6.0.0)
326 stringio (3.2.0)
327 + stripe (13.5.1)
328 tailwindcss-rails (3.3.2)
329 railties (>= 7.0.0)
330 tailwindcss-ruby (~> 3.0)
@@ -378,6 +379,7 @@ DEPENDENCIES
379 solid_cache
380 solid_queue
381 stimulus-rails
382 + stripe (~> 13)
383 tailwindcss-rails (~> 3.3.1)
384 thruster
385 turbo-rails
app/controllers/api/v1/billing_controller.rb new
+61
@@ -0,0 +1,61 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # siGit Code billing for the authenticated user. The desktop app / CLI call
6 + # these with the smbCloud bearer token; checkout/portal return a URL the
7 + # client opens in a browser (purchases happen on the web, not in-app, to
8 + # avoid the App Store cut).
9 + class BillingController < Api::BaseController
10 + before_action :authenticate_token!
11 +
12 + # GET /api/v1/billing — current plan + cloud entitlement.
13 + def show
14 + subscription = current_user.subscription
15 + render json: {
16 + plan: subscription&.plan || "free",
17 + status: subscription&.status || "active",
18 + entitled_to_cloud: current_user.entitled_to_cloud?,
19 + current_period_end: subscription&.current_period_end
20 + }, status: :ok
21 + end
22 +
23 + # POST /api/v1/billing/checkout { plan: "pro" | "team" } → { url }
24 + def checkout
25 + return render_billing_unavailable unless StripeService.configured?
26 +
27 + plan = params[:plan].presence || "pro"
28 + unless StripeService::PRICE_LOOKUP_KEYS.key?(plan)
29 + return render_error(ERR_INVALID, "Unknown plan.", status: :unprocessable_entity)
30 + end
31 +
32 + url = StripeService.checkout_url(
33 + user: current_user,
34 + plan: plan,
35 + success_url: "#{request.base_url}/settings?billing=success",
36 + cancel_url: "#{request.base_url}/settings?billing=cancel"
37 + )
38 + render json: { url: url }, status: :ok
39 + end
40 +
41 + # POST /api/v1/billing/portal → { url }
42 + def portal
43 + return render_billing_unavailable unless StripeService.configured?
44 +
45 + url = StripeService.portal_url(
46 + user: current_user,
47 + return_url: "#{request.base_url}/settings"
48 + )
49 + return render_error(ERR_INVALID, "No subscription to manage.", status: :not_found) unless url
50 +
51 + render json: { url: url }, status: :ok
52 + end
53 +
54 + private
55 +
56 + def render_billing_unavailable
57 + render_error(ERR_UNKNOWN, "Billing is not configured.", status: :service_unavailable)
58 + end
59 + end
60 + end
61 +end
app/controllers/api/v1/chat_completions_controller.rb
+14
@@ -20,6 +20,7 @@ module Api
20 include ActionController::Live
21
22 before_action :authenticate_token!
23 + before_action :enforce_cloud_entitlement!
24
25 def create
26 if streaming_requested?
@@ -33,6 +34,19 @@ module Api
34
35 private
36
37 + # Gate the paid cloud tiers behind a siGit Code Pro subscription. On-device
38 + # models are always free and pass through. Runs before any streaming starts,
39 + # so a gated request gets a normal 402 JSON body, not a half-open SSE stream.
40 + def enforce_cloud_entitlement!
41 + model = completion_payload["model"]
42 + return unless CloudCatalog.cloud_tier?(model)
43 + return if current_user&.entitled_to_cloud?
44 +
45 + render json: error_body(
46 + "siGit Code Cloud requires a Pro subscription. Upgrade in your siGit account to use cloud models."
47 + ), status: :payment_required
48 + end
49 +
50 # Pipe Onde Cloud's SSE response straight through to the client.
51 def stream_completion
52 response.headers["Content-Type"] = "text/event-stream"
app/controllers/stripe_webhooks_controller.rb new
+45
@@ -0,0 +1,45 @@
1 +# frozen_string_literal: true
2 +
3 +# Receives Stripe webhooks for siGit Code billing and syncs the local
4 +# Subscription cache. Public endpoint, authenticated by the Stripe signature —
5 +# never a user token.
6 +class StripeWebhooksController < ActionController::API
7 + def create
8 + payload = request.body.read
9 + signature = request.headers["Stripe-Signature"]
10 +
11 + event =
12 + begin
13 + StripeService.construct_event(payload, signature)
14 + rescue JSON::ParserError, Stripe::SignatureVerificationError => e
15 + Rails.logger.warn("Stripe webhook rejected: #{e.class}: #{e.message}")
16 + return head :bad_request
17 + rescue KeyError
18 + Rails.logger.error("Stripe webhook secret not configured")
19 + return head :service_unavailable
20 + end
21 +
22 + handle(event)
23 + head :ok
24 + rescue StandardError => e
25 + Rails.logger.error("Stripe webhook handling failed: #{e.message}")
26 + head :ok # ack so Stripe doesn't retry a poison event forever; we logged it
27 + end
28 +
29 + private
30 +
31 + def handle(event)
32 + case event.type
33 + when "checkout.session.completed"
34 + session = event.data.object
35 + subscription_id = session.subscription
36 + StripeService.sync_subscription(Stripe::Subscription.retrieve(subscription_id)) if subscription_id
37 + when "customer.subscription.created",
38 + "customer.subscription.updated",
39 + "customer.subscription.deleted"
40 + StripeService.sync_subscription(event.data.object)
41 + else
42 + Rails.logger.debug("Stripe webhook ignored: #{event.type}")
43 + end
44 + end
45 +end
app/models/subscription.rb new
+16
@@ -0,0 +1,16 @@
1 +# frozen_string_literal: true
2 +
3 +# A user's siGit plan + Stripe subscription state. Source of truth is Stripe;
4 +# this caches what the cloud gate needs. A user with no Subscription row is
5 +# treated as Free (local-only, no cloud).
6 +class Subscription < ApplicationRecord
7 + belongs_to :user
8 +
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 + # Entitled to siGit Code Cloud: on a paid plan in good standing.
13 + def entitled_to_cloud?
14 + (pro? || team?) && (active? || trialing?)
15 + end
16 +end
app/models/user.rb
+7
@@ -5,6 +5,13 @@ class User < ApplicationRecord
5 has_many :ssh_keys, dependent: :destroy
6 has_many :stars, dependent: :destroy
7 has_many :starred_repositories, through: :stars, source: :repository
8 + has_one :subscription, dependent: :destroy
9 +
10 + # Whether this user may use siGit Code Cloud (the paid cloud tiers). Free /
11 + # unsubscribed users run on-device only.
12 + def entitled_to_cloud?
13 + subscription&.entitled_to_cloud? || false
14 + end
15
16 validates :smbcloud_id,
17 presence: true,
app/services/cloud_catalog.rb new
+16
@@ -0,0 +1,16 @@
1 +# frozen_string_literal: true
2 +
3 +# Classifies a requested model as a paid siGit Code Cloud tier vs a free
4 +# on-device model. The neutral cloud tiers (Fast/Balanced/Large) and the legacy
5 +# `claude-*` / `anthropic/*` aliases run on Onde Cloud and cost money; everything
6 +# else (GGUF `owner/repo/file` ids) runs on the user's own device and is free.
7 +class CloudCatalog
8 + CLOUD_TIERS = %w[onde-fast onde-balanced onde-large].freeze
9 +
10 + def self.cloud_tier?(model)
11 + id = model.to_s.strip.downcase
12 + return false if id.empty?
13 +
14 + CLOUD_TIERS.include?(id) || id.start_with?("claude-", "anthropic/")
15 + end
16 +end
app/services/stripe_service.rb new
+105
@@ -0,0 +1,105 @@
1 +# frozen_string_literal: true
2 +
3 +# siGit's Stripe integration for siGit Code Pro/Team. Stripe is the source of
4 +# truth; we cache plan + status into Subscription so the cloud gate decides
5 +# without a Stripe round-trip. Entirely separate from Onde Inference's billing.
6 +class StripeService
7 + # Our plan → the Stripe price lookup_key (prices created in Stripe).
8 + PRICE_LOOKUP_KEYS = {
9 + "pro" => "sigit_code_pro_monthly",
10 + "team" => "sigit_code_team_monthly"
11 + }.freeze
12 +
13 + # A subscription's price lookup_key → our plan.
14 + PLAN_BY_LOOKUP_KEY = {
15 + "sigit_code_pro_monthly" => :pro,
16 + "sigit_code_team_monthly" => :team
17 + }.freeze
18 +
19 + # Stripe subscription status → our Subscription status enum.
20 + STATUS_MAP = {
21 + "active" => :active,
22 + "trialing" => :trialing,
23 + "past_due" => :past_due,
24 + "unpaid" => :past_due,
25 + "canceled" => :canceled,
26 + "incomplete_expired" => :canceled,
27 + "incomplete" => :incomplete,
28 + "paused" => :incomplete
29 + }.freeze
30 +
31 + class << self
32 + def configured?
33 + ENV["STRIPE_SECRET_KEY"].present?
34 + end
35 +
36 + # Create a Checkout session for a user + plan; returns the redirect URL.
37 + def checkout_url(user:, plan:, success_url:, cancel_url:)
38 + price_id = price_id_for(plan)
39 + raise ArgumentError, "Unknown plan #{plan}" unless price_id
40 +
41 + existing_customer = user.subscription&.stripe_customer_id
42 +
43 + session = Stripe::Checkout::Session.create({
44 + mode: "subscription",
45 + line_items: [{ price: price_id, quantity: 1 }],
46 + client_reference_id: user.id.to_s,
47 + customer: existing_customer,
48 + customer_email: existing_customer ? nil : user.email,
49 + allow_promotion_codes: true,
50 + success_url: success_url,
51 + cancel_url: cancel_url,
52 + metadata: { user_id: user.id.to_s },
53 + subscription_data: { metadata: { user_id: user.id.to_s } }
54 + }.compact)
55 +
56 + session.url
57 + end
58 +
59 + # Create a Billing Portal session for a user; returns the URL. nil when the
60 + # user has no Stripe customer yet (never subscribed).
61 + def portal_url(user:, return_url:)
62 + customer = user.subscription&.stripe_customer_id
63 + return nil unless customer
64 +
65 + Stripe::BillingPortal::Session.create(customer: customer, return_url: return_url).url
66 + end
67 +
68 + # Verify + parse a webhook payload into a Stripe::Event.
69 + def construct_event(payload, signature_header)
70 + Stripe::Webhook.construct_event(payload, signature_header, ENV.fetch("STRIPE_WEBHOOK_SECRET"))
71 + end
72 +
73 + # Upsert the local Subscription from a Stripe subscription object. Returns the
74 + # Subscription, or nil when the subscription isn't attributable to a user.
75 + def sync_subscription(stripe_subscription)
76 + user_id = stripe_subscription.metadata && stripe_subscription.metadata["user_id"]
77 + user = User.find_by(id: user_id)
78 + return nil unless user
79 +
80 + price = stripe_subscription.items&.data&.first&.price
81 + plan = PLAN_BY_LOOKUP_KEY[price&.lookup_key] || :free
82 + status = STATUS_MAP[stripe_subscription.status] || :incomplete
83 + period_end = stripe_subscription.current_period_end
84 +
85 + subscription = user.subscription || user.build_subscription
86 + subscription.update!(
87 + plan: plan,
88 + status: status,
89 + stripe_customer_id: stripe_subscription.customer,
90 + stripe_subscription_id: stripe_subscription.id,
91 + current_period_end: period_end ? Time.at(period_end) : nil
92 + )
93 + subscription
94 + end
95 +
96 + private
97 +
98 + def price_id_for(plan)
99 + lookup_key = PRICE_LOOKUP_KEYS[plan.to_s]
100 + return nil unless lookup_key
101 +
102 + Stripe::Price.list(lookup_keys: [lookup_key], active: true, limit: 1).data.first&.id
103 + end
104 + end
105 +end
config/initializers/stripe.rb new
+5
@@ -0,0 +1,5 @@
1 +# frozen_string_literal: true
2 +
3 +# siGit's own Stripe API key (siGit Code Pro/Team). Separate product line from
4 +# Onde Inference's billing, though it may share the Stripe account.
5 +Stripe.api_key = ENV["STRIPE_SECRET_KEY"] if ENV["STRIPE_SECRET_KEY"].present?
config/routes.rb
+6
@@ -72,9 +72,15 @@ Rails.application.routes.draw do
72 post "repos", to: "repos#create"
73 post "git_credentials", to: "git_credentials#create"
74 post "chat/completions", to: "chat_completions#create"
75 + get "billing", to: "billing#show"
76 + post "billing/checkout", to: "billing#checkout"
77 + post "billing/portal", to: "billing#portal"
78 end
79 end
80
81 + # Stripe webhooks for siGit Code billing (authenticated by Stripe signature).
82 + post "/stripe/webhooks", to: "stripe_webhooks#create"
83 +
84 # Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before
85 # the catch-all "/:username" routes; the `*.git` constraint keeps them from
86 # matching normal repo-browsing URLs.
db/migrate/20250101000005_create_subscriptions.rb new
+24
@@ -0,0 +1,24 @@
1 +# frozen_string_literal: true
2 +
3 +# A siGit user's subscription. siGit Code is local-first and free; a paid plan
4 +# unlocks siGit Code Cloud (the Fast/Balanced/Large tiers). Stripe is siGit's own
5 +# — separate from Onde Inference's billing — and is the source of truth; this row
6 +# caches plan + status so the cloud gate can decide without a Stripe round-trip.
7 +class CreateSubscriptions < ActiveRecord::Migration[8.1]
8 + def change
9 + create_table :subscriptions do |t|
10 + t.references :user, null: false, foreign_key: true, index: { unique: true }
11 + t.integer :plan, null: false, default: 0 # free=0, pro=1, team=2
12 + t.integer :status, null: false, default: 0 # active=0, past_due=1, canceled=2, trialing=3, incomplete=4
13 +
14 + t.string :stripe_customer_id
15 + t.string :stripe_subscription_id
16 + t.datetime :current_period_end
17 +
18 + t.timestamps
19 + end
20 +
21 + add_index :subscriptions, :stripe_customer_id
22 + add_index :subscriptions, :stripe_subscription_id
23 + end
24 +end
db/schema.rb
+16 -1
@@ -10,7 +10,7 @@
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_000004) do
13 +ActiveRecord::Schema[8.1].define(version: 2025_01_01_000005) do
14 # These are extensions that must be enabled in order to support this database
15 enable_extension "pg_catalog.plpgsql"
16
@@ -51,6 +51,20 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do
51 t.index ["user_id"], name: "index_stars_on_user_id"
52 end
53
54 + create_table "subscriptions", force: :cascade do |t|
55 + t.datetime "created_at", null: false
56 + t.datetime "current_period_end"
57 + t.integer "plan", default: 0, null: false
58 + t.integer "status", default: 0, null: false
59 + t.string "stripe_customer_id"
60 + t.string "stripe_subscription_id"
61 + t.datetime "updated_at", null: false
62 + t.bigint "user_id", null: false
63 + t.index ["stripe_customer_id"], name: "index_subscriptions_on_stripe_customer_id"
64 + t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id"
65 + t.index ["user_id"], name: "index_subscriptions_on_user_id", unique: true
66 + end
67 +
68 create_table "users", force: :cascade do |t|
69 t.string "access_token"
70 t.string "avatar_url"
@@ -69,4 +83,5 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000004) do
83 add_foreign_key "ssh_keys", "users"
84 add_foreign_key "stars", "repositories"
85 add_foreign_key "stars", "users"
86 + add_foreign_key "subscriptions", "users"
87 end