Add web billing page for siGit Code plans
A session-authed /billing page showing the plan, cloud usage this period (used / allowance with a progress bar), and Upgrade / Manage billing actions via Stripe Checkout and the Billing Portal. Linked from Settings. - BillingController (show/checkout/portal), web routes - billing/show view (Pro and Free states) matching the site's card style - API checkout/portal return URLs aligned to /billing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seto Elkahfi committed
Jun 23, 2026 at 22:27 UTC
86ba75b0ace6b8ffc3e6197db8c5c77f31bfd7d6
5 files changed
+134
-3
app/controllers/api/v1/billing_controller.rb
+3
-3
@@ -34,8 +34,8 @@ module Api
34
url = StripeService.checkout_url(
35
user: current_user,
36
plan: plan,
37
- success_url: "#{request.base_url}/settings?billing=success",
38
- cancel_url: "#{request.base_url}/settings?billing=cancel"
37
+ success_url: "#{request.base_url}/billing?billing=success",
38
+ cancel_url: "#{request.base_url}/billing?billing=cancel"
39
)
40
render json: { url: url }, status: :ok
41
end
@@ -46,7 +46,7 @@ module Api
46
47
url = StripeService.portal_url(
48
user: current_user,
49
- return_url: "#{request.base_url}/settings"
49
+ return_url: "#{request.base_url}/billing"
50
)
51
return render_error(ERR_INVALID, "No subscription to manage.", status: :not_found) unless url
52
app/controllers/billing_controller.rb
new
+46
@@ -0,0 +1,46 @@
1
+# frozen_string_literal: true
2
+
3
+# Web billing page for siGit Code (session-authenticated). Shows the plan + cloud
4
+# usage and starts Stripe Checkout / Billing Portal sessions. Purchases happen on
5
+# the web so the desktop app can link here and avoid the App Store cut.
6
+class BillingController < ApplicationController
7
+ before_action :require_sign_in!
8
+
9
+ def show
10
+ @subscription = current_user.subscription
11
+ @plan = @subscription&.plan || "free"
12
+ @status = @subscription&.status
13
+ @entitled = current_user.entitled_to_cloud?
14
+ @used = current_user.cloud_requests_used
15
+ @allowance = current_user.cloud_allowance
16
+ @period_end = @subscription&.current_period_end
17
+ end
18
+
19
+ def checkout
20
+ return redirect_to billing_path, alert: "Billing is not configured." unless StripeService.configured?
21
+
22
+ plan = StripeService::PRICE_LOOKUP_KEYS.key?(params[:plan]) ? params[:plan] : "pro"
23
+ url = StripeService.checkout_url(
24
+ user: current_user,
25
+ plan: plan,
26
+ success_url: billing_url(billing: "success"),
27
+ cancel_url: billing_url(billing: "cancel")
28
+ )
29
+ redirect_to url, allow_other_host: true
30
+ rescue StandardError => e
31
+ Rails.logger.error("Stripe checkout failed: #{e.message}")
32
+ redirect_to billing_path, alert: "Could not start checkout. Please try again."
33
+ end
34
+
35
+ def portal
36
+ return redirect_to billing_path, alert: "Billing is not configured." unless StripeService.configured?
37
+
38
+ url = StripeService.portal_url(user: current_user, return_url: billing_url)
39
+ return redirect_to billing_path, alert: "No subscription to manage yet." unless url
40
+
41
+ redirect_to url, allow_other_host: true
42
+ rescue StandardError => e
43
+ Rails.logger.error("Stripe portal failed: #{e.message}")
44
+ redirect_to billing_path, alert: "Could not open the billing portal."
45
+ end
46
+end
app/views/billing/show.html.erb
new
+70
@@ -0,0 +1,70 @@
1
+<% content_for :title, "Billing" %>
2
+
3
+<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
4
+ <h1 class="text-xl font-semibold text-gray-100 mb-8">Billing</h1>
5
+
6
+ <% if params[:billing] == "success" %>
7
+ <div class="card mb-6 px-6 py-4 border border-green-800/40 bg-green-900/20 text-sm text-green-300">
8
+ You're on siGit Code Cloud. It may take a moment to activate.
9
+ </div>
10
+ <% elsif params[:billing] == "cancel" %>
11
+ <div class="card mb-6 px-6 py-4 border border-amber-800/40 bg-amber-900/20 text-sm text-amber-300">
12
+ Checkout canceled — no changes were made.
13
+ </div>
14
+ <% end %>
15
+
16
+ <!-- Plan -->
17
+ <div class="card mb-6">
18
+ <div class="px-6 py-4 border-b border-surface-600 flex items-center justify-between">
19
+ <h2 class="text-sm font-semibold text-gray-100">Plan</h2>
20
+ <span class="text-xs px-2 py-0.5 rounded-full <%= @entitled ? "bg-brand-500/15 text-brand-400" : "bg-surface-700 text-gray-400" %>">
21
+ <%= @plan.to_s.capitalize %><%= " · #{@status}" if @status && @status != "active" %>
22
+ </span>
23
+ </div>
24
+ <div class="px-6 py-6">
25
+ <% if @entitled %>
26
+ <p class="text-sm text-gray-300">
27
+ You're on <span class="text-gray-100 font-medium"><%= @plan.to_s.capitalize %></span> — siGit Code Cloud is unlocked.
28
+ <% if @period_end %>
29
+ <span class="text-gray-500">Renews <%= @period_end.strftime("%b %-d, %Y") %>.</span>
30
+ <% end %>
31
+ </p>
32
+ <% else %>
33
+ <p class="text-sm text-gray-300">
34
+ You're on the free <span class="text-gray-100 font-medium">Garage</span> plan. AI runs
35
+ <span class="text-gray-100">on your device</span> — private and free. Upgrade to use
36
+ siGit Code Cloud (the Fast / Balanced / Large tiers) when you need more.
37
+ </p>
38
+ <% end %>
39
+ </div>
40
+ </div>
41
+
42
+ <!-- Cloud usage -->
43
+ <% if @entitled %>
44
+ <div class="card mb-6">
45
+ <div class="px-6 py-4 border-b border-surface-600">
46
+ <h2 class="text-sm font-semibold text-gray-100">Cloud usage this period</h2>
47
+ </div>
48
+ <div class="px-6 py-6">
49
+ <div class="flex items-baseline justify-between mb-2">
50
+ <span class="text-sm text-gray-300"><%= number_with_delimiter(@used) %> / <%= number_with_delimiter(@allowance) %> requests</span>
51
+ <span class="text-xs text-gray-500"><%= @allowance.positive? ? (100 * @used / @allowance).clamp(0, 100) : 0 %>%</span>
52
+ </div>
53
+ <div class="h-2 w-full rounded-full bg-surface-700 overflow-hidden">
54
+ <div class="h-full bg-brand-500" style="width: <%= @allowance.positive? ? (100 * @used / @allowance).clamp(0, 100) : 0 %>%"></div>
55
+ </div>
56
+ <p class="text-xs text-gray-500 mt-3">On-device requests are always free and don't count toward this.</p>
57
+ </div>
58
+ </div>
59
+ <% end %>
60
+
61
+ <!-- Actions -->
62
+ <div class="flex flex-wrap items-center gap-4">
63
+ <% if @entitled %>
64
+ <%= button_to "Manage billing", billing_portal_path, method: :post, class: "btn-primary cursor-pointer" %>
65
+ <% else %>
66
+ <%= button_to "Upgrade to Pro", billing_checkout_path(plan: "pro"), method: :post, class: "btn-primary cursor-pointer" %>
67
+ <%= button_to "Upgrade to Team", billing_checkout_path(plan: "team"), method: :post, class: "text-sm text-gray-400 hover:text-gray-200 transition-colors bg-transparent border-0 cursor-pointer" %>
68
+ <% end %>
69
+ </div>
70
+</div>
app/views/users/settings.html.erb
+10
@@ -31,4 +31,14 @@
31
<% end %>
32
</div>
33
</div>
34
+
35
+ <div class="card mt-6">
36
+ <div class="px-6 py-4 border-b border-surface-600">
37
+ <h2 class="text-sm font-semibold text-gray-100">Billing</h2>
38
+ </div>
39
+ <div class="px-6 py-6 flex items-center justify-between">
40
+ <p class="text-sm text-gray-400">Manage your siGit Code plan and cloud usage.</p>
41
+ <%= link_to "Billing", billing_path, class: "btn-primary" %>
42
+ </div>
43
+ </div>
44
</div>
config/routes.rb
+5
@@ -56,6 +56,11 @@ Rails.application.routes.draw do
56
get "/settings", to: "users#settings", as: :settings
57
patch "/settings", to: "users#update_settings"
58
59
+ # Billing — siGit Code plans (web). Checkout/portal happen on the web.
60
+ get "/billing", to: "billing#show", as: :billing
61
+ post "/billing/checkout", to: "billing#checkout", as: :billing_checkout
62
+ post "/billing/portal", to: "billing#portal", as: :billing_portal
63
+
64
# JSON API — token-based auth for the siGit Code & Deploy desktop app.
65
# Declared before the catch-all "/:username" route so "/api/..." isn't
66
# swallowed by the username matcher.