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
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"
Gemfile.lock
+2
index bd7e847..2f041e0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -324,6 +324,7 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) + stripe (13.5.1) tailwindcss-rails (3.3.2) railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) @@ -378,6 +379,7 @@ DEPENDENCIES solid_cache solid_queue stimulus-rails + stripe (~> 13) tailwindcss-rails (~> 3.3.1) thruster turbo-rails
app/controllers/api/v1/billing_controller.rb
+61
new file mode 100644 index 0000000..8391a0b --- /dev/null +++ b/app/controllers/api/v1/billing_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Api + module V1 + # siGit Code billing for the authenticated user. The desktop app / CLI call + # these with the smbCloud bearer token; checkout/portal return a URL the + # client opens in a browser (purchases happen on the web, not in-app, to + # avoid the App Store cut). + class BillingController < Api::BaseController + before_action :authenticate_token! + + # GET /api/v1/billing — current plan + cloud entitlement. + def show + subscription = current_user.subscription + render json: { + plan: subscription&.plan || "free", + status: subscription&.status || "active", + entitled_to_cloud: current_user.entitled_to_cloud?, + current_period_end: subscription&.current_period_end + }, status: :ok + end + + # POST /api/v1/billing/checkout { plan: "pro" | "team" } → { url } + def checkout + return render_billing_unavailable unless StripeService.configured? + + plan = params[:plan].presence || "pro" + unless StripeService::PRICE_LOOKUP_KEYS.key?(plan) + return render_error(ERR_INVALID, "Unknown plan.", status: :unprocessable_entity) + end + + url = StripeService.checkout_url( + user: current_user, + plan: plan, + success_url: "#{request.base_url}/settings?billing=success", + cancel_url: "#{request.base_url}/settings?billing=cancel" + ) + render json: { url: url }, status: :ok + end + + # POST /api/v1/billing/portal → { url } + def portal + return render_billing_unavailable unless StripeService.configured? + + url = StripeService.portal_url( + user: current_user, + return_url: "#{request.base_url}/settings" + ) + return render_error(ERR_INVALID, "No subscription to manage.", status: :not_found) unless url + + render json: { url: url }, status: :ok + end + + private + + def render_billing_unavailable + render_error(ERR_UNKNOWN, "Billing is not configured.", status: :service_unavailable) + end + end + end +end
app/controllers/api/v1/chat_completions_controller.rb
+14
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 100644 index 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 100644 index 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 100644 index 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 100644 index 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 100644 index 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 100644 index 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