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
index a845c15..39faf4d 100644 --- a/app/controllers/api/v1/billing_controller.rb +++ b/app/controllers/api/v1/billing_controller.rb @@ -34,8 +34,8 @@ module Api 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" + success_url: "#{request.base_url}/billing?billing=success", + cancel_url: "#{request.base_url}/billing?billing=cancel" ) render json: { url: url }, status: :ok end @@ -46,7 +46,7 @@ module Api url = StripeService.portal_url( user: current_user, - return_url: "#{request.base_url}/settings" + return_url: "#{request.base_url}/billing" ) return render_error(ERR_INVALID, "No subscription to manage.", status: :not_found) unless url
app/controllers/billing_controller.rb
+46
new file mode 100644 index 0000000..13846ad --- /dev/null +++ b/app/controllers/billing_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Web billing page for siGit Code (session-authenticated). Shows the plan + cloud +# usage and starts Stripe Checkout / Billing Portal sessions. Purchases happen on +# the web so the desktop app can link here and avoid the App Store cut. +class BillingController < ApplicationController + before_action :require_sign_in! + + def show + @subscription = current_user.subscription + @plan = @subscription&.plan || "free" + @status = @subscription&.status + @entitled = current_user.entitled_to_cloud? + @used = current_user.cloud_requests_used + @allowance = current_user.cloud_allowance + @period_end = @subscription&.current_period_end + end + + def checkout + return redirect_to billing_path, alert: "Billing is not configured." unless StripeService.configured? + + plan = StripeService::PRICE_LOOKUP_KEYS.key?(params[:plan]) ? params[:plan] : "pro" + url = StripeService.checkout_url( + user: current_user, + plan: plan, + success_url: billing_url(billing: "success"), + cancel_url: billing_url(billing: "cancel") + ) + redirect_to url, allow_other_host: true + rescue StandardError => e + Rails.logger.error("Stripe checkout failed: #{e.message}") + redirect_to billing_path, alert: "Could not start checkout. Please try again." + end + + def portal + return redirect_to billing_path, alert: "Billing is not configured." unless StripeService.configured? + + url = StripeService.portal_url(user: current_user, return_url: billing_url) + return redirect_to billing_path, alert: "No subscription to manage yet." unless url + + redirect_to url, allow_other_host: true + rescue StandardError => e + Rails.logger.error("Stripe portal failed: #{e.message}") + redirect_to billing_path, alert: "Could not open the billing portal." + end +end
app/views/billing/show.html.erb
+70
new file mode 100644 index 0000000..6a73943 --- /dev/null +++ b/app/views/billing/show.html.erb @@ -0,0 +1,70 @@ +<% content_for :title, "Billing" %> + +<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12"> + <h1 class="text-xl font-semibold text-gray-100 mb-8">Billing</h1> + + <% if params[:billing] == "success" %> + <div class="card mb-6 px-6 py-4 border border-green-800/40 bg-green-900/20 text-sm text-green-300"> + You're on siGit Code Cloud. It may take a moment to activate. + </div> + <% elsif params[:billing] == "cancel" %> + <div class="card mb-6 px-6 py-4 border border-amber-800/40 bg-amber-900/20 text-sm text-amber-300"> + Checkout canceled — no changes were made. + </div> + <% end %> + + <!-- Plan --> + <div class="card mb-6"> + <div class="px-6 py-4 border-b border-surface-600 flex items-center justify-between"> + <h2 class="text-sm font-semibold text-gray-100">Plan</h2> + <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" %>"> + <%= @plan.to_s.capitalize %><%= " · #{@status}" if @status && @status != "active" %> + </span> + </div> + <div class="px-6 py-6"> + <% if @entitled %> + <p class="text-sm text-gray-300"> + You're on <span class="text-gray-100 font-medium"><%= @plan.to_s.capitalize %></span> — siGit Code Cloud is unlocked. + <% if @period_end %> + <span class="text-gray-500">Renews <%= @period_end.strftime("%b %-d, %Y") %>.</span> + <% end %> + </p> + <% else %> + <p class="text-sm text-gray-300"> + You're on the free <span class="text-gray-100 font-medium">Garage</span> plan. AI runs + <span class="text-gray-100">on your device</span> — private and free. Upgrade to use + siGit Code Cloud (the Fast / Balanced / Large tiers) when you need more. + </p> + <% end %> + </div> + </div> + + <!-- Cloud usage --> + <% if @entitled %> + <div class="card mb-6"> + <div class="px-6 py-4 border-b border-surface-600"> + <h2 class="text-sm font-semibold text-gray-100">Cloud usage this period</h2> + </div> + <div class="px-6 py-6"> + <div class="flex items-baseline justify-between mb-2"> + <span class="text-sm text-gray-300"><%= number_with_delimiter(@used) %> / <%= number_with_delimiter(@allowance) %> requests</span> + <span class="text-xs text-gray-500"><%= @allowance.positive? ? (100 * @used / @allowance).clamp(0, 100) : 0 %>%</span> + </div> + <div class="h-2 w-full rounded-full bg-surface-700 overflow-hidden"> + <div class="h-full bg-brand-500" style="width: <%= @allowance.positive? ? (100 * @used / @allowance).clamp(0, 100) : 0 %>%"></div> + </div> + <p class="text-xs text-gray-500 mt-3">On-device requests are always free and don't count toward this.</p> + </div> + </div> + <% end %> + + <!-- Actions --> + <div class="flex flex-wrap items-center gap-4"> + <% if @entitled %> + <%= button_to "Manage billing", billing_portal_path, method: :post, class: "btn-primary cursor-pointer" %> + <% else %> + <%= button_to "Upgrade to Pro", billing_checkout_path(plan: "pro"), method: :post, class: "btn-primary cursor-pointer" %> + <%= 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" %> + <% end %> + </div> +</div>
app/views/users/settings.html.erb
+10
index ee67e96..92f3b25 100644 --- a/app/views/users/settings.html.erb +++ b/app/views/users/settings.html.erb @@ -31,4 +31,14 @@ <% end %> </div> </div> + + <div class="card mt-6"> + <div class="px-6 py-4 border-b border-surface-600"> + <h2 class="text-sm font-semibold text-gray-100">Billing</h2> + </div> + <div class="px-6 py-6 flex items-center justify-between"> + <p class="text-sm text-gray-400">Manage your siGit Code plan and cloud usage.</p> + <%= link_to "Billing", billing_path, class: "btn-primary" %> + </div> + </div> </div>
config/routes.rb
+5
index ff6473a..e187ac1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,11 @@ Rails.application.routes.draw do get "/settings", to: "users#settings", as: :settings patch "/settings", to: "users#update_settings" + # Billing — siGit Code plans (web). Checkout/portal happen on the web. + get "/billing", to: "billing#show", as: :billing + post "/billing/checkout", to: "billing#checkout", as: :billing_checkout + post "/billing/portal", to: "billing#portal", as: :billing_portal + # JSON API — token-based auth for the siGit Code & Deploy desktop app. # Declared before the catch-all "/:username" route so "/api/..." isn't # swallowed by the username matcher.