Add billing system with Stripe

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Jason Zimdars <jz@37signals.com>
This commit is contained in:
Jorge Manrubia
2025-12-10 16:08:38 +01:00
parent 76400765c1
commit ee80b87c8c
18 changed files with 86 additions and 197 deletions
+1 -1
View File
@@ -3,8 +3,8 @@ eval_gemfile "Gemfile"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
# SaaS-only functionality
gem "activeresource", require: "active_resource" gem "activeresource", require: "active_resource"
gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin" gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", bc: "fizzy-saas" gem "fizzy-saas", bc: "fizzy-saas"
gem "console1984", bc: "console1984" gem "console1984", bc: "console1984"
+3 -1
View File
@@ -21,7 +21,7 @@ GIT
GIT GIT
remote: https://github.com/basecamp/fizzy-saas remote: https://github.com/basecamp/fizzy-saas
revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143 revision: 8eaa692316bb27ed73bd628b4735082585498ca0
specs: specs:
fizzy-saas (0.1.0) fizzy-saas (0.1.0)
audits1984 audits1984
@@ -558,6 +558,7 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.9) stringio (3.1.9)
stripe (18.0.1)
thor (1.4.0) thor (1.4.0)
thruster (0.1.17) thruster (0.1.17)
thruster (0.1.17-aarch64-linux) thruster (0.1.17-aarch64-linux)
@@ -684,6 +685,7 @@ DEPENDENCIES
solid_queue (~> 1.2) solid_queue (~> 1.2)
sqlite3 (>= 2.0) sqlite3 (>= 2.0)
stimulus-rails stimulus-rails
stripe (~> 18.0)
thruster thruster
trilogy (~> 2.9) trilogy (~> 2.9)
turbo-rails turbo-rails
+9
View File
@@ -71,6 +71,15 @@
33% { background-color: var(--color-border-darker); scale: 1; } 33% { background-color: var(--color-border-darker); scale: 1; }
} }
@keyframes wiggle {
0% { transform: rotate(0deg); }
20% { transform: rotate(3deg); }
40% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
80% { transform: rotate(-3deg); }
100% { transform: rotate(0deg); }
}
@keyframes wobble { @keyframes wobble {
0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); } 0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); }
15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; } 15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; }
+1 -1
View File
@@ -277,7 +277,7 @@
border-radius: 99rem; border-radius: 99rem;
} }
.btn:not(.popup__btn, .btn--plain, .btn--reversed) { .btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) {
--btn-background: var(--card-color); --btn-background: var(--card-color);
--btn-color: var(--color-ink-inverted); --btn-color: var(--color-ink-inverted);
} }
+17
View File
@@ -184,6 +184,23 @@
.fill-highlight { background-color: var(--color-highlight); } .fill-highlight { background-color: var(--color-highlight); }
.fill-transparent { background-color: transparent; } .fill-transparent { background-color: transparent; }
.fill-highlighter {
display: inline-block;
position: relative;
z-index: 1;
&::before {
background-color: var(--color-highlight);
border-radius: 0.2em;
content: "";
inset-block: 0;
inset-inline: -0.1em;
position: absolute;
transform: skewX(-10deg) rotate(1deg);
z-index: -1;
}
}
.translucent { opacity: var(--opacity, 0.66); } .translucent { opacity: var(--opacity, 0.66); }
/* Borders */ /* Borders */
-20
View File
@@ -1,20 +0,0 @@
class Admin::StatsController < AdminController
layout "public"
def show
@accounts_total = Account.count
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
@identities_total = Identity.count
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
@identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count
@top_accounts = Account
.where("cards_count > 0")
.order(cards_count: :desc)
.limit(20)
@recent_accounts = Account.order(created_at: :desc).limit(10)
end
end
+2
View File
@@ -0,0 +1,2 @@
module Admin
end
+2
View File
@@ -20,3 +20,5 @@
<%= render "account/settings/export" %> <%= render "account/settings/export" %>
</div> </div>
</section> </section>
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
-126
View File
@@ -1,126 +0,0 @@
<% @page_title = "Account Statistics" %>
<% content_for :header do %>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<section class="settings">
<div class="settings__panel panel shadow center">
<div class="flex flex-column gap margin-block-half">
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Accounts Created</h2>
</header>
<div class="flex gap-half">
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">Total</div>
<div class="txt-large">
<strong><%= @accounts_total %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">7 days</div>
<div class="txt-large">
<strong><%= @accounts_last_7_days %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">24 hours</div>
<div class="txt-large">
<strong><%= @accounts_last_24_hours %></strong>
</div>
</div>
</div>
</div>
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Identities Created</h2>
</header>
<div class="flex gap-half">
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">Total</div>
<div class="txt-large">
<strong><%= @identities_total %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">7 days</div>
<div class="txt-large">
<strong><%= @identities_last_7_days %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">24 hours</div>
<div class="txt-large">
<strong><%= @identities_last_24_hours %></strong>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="settings__panel panel shadow center">
<header>
<h2 class="divider txt-medium margin-block-start">
10 Most Recent Signups
</h2>
</header>
<ul class="margin-block-half">
<% @recent_accounts.each do |account| %>
<% admin_user = account.users.owner.first %>
<li class="flex align-start gap-half margin-block-start-half">
<div
class="flex-item-grow min-width overflow-ellipsis"
style="text-align: left"
>
<strong><%= account.name %></strong>
<br>
<span class="txt-x-small txt-ink-medium">
#<%= account.external_account_id %> •
<%= admin_user&.identity&.email_address || "No admin" %>
</span>
</div>
<div class="txt-medium txt-ink-medium" style="text-align: right">
<%= time_ago_in_words(account.created_at) %> ago
</div>
</li>
<% end %>
</ul>
</div>
<div class="settings__panel panel shadow center">
<header>
<h2 class="divider txt-medium margin-block-start">
Top 20 Accounts by Card Count
</h2>
</header>
<ul class="margin-block-half">
<% @top_accounts.each do |account| %>
<% admin_user = account.users.owner.first %>
<li class="flex align-start gap-half margin-block-start-half">
<div
class="flex-item-grow min-width overflow-ellipsis"
style="text-align: left"
>
<strong><%= account.name %></strong>
<br>
<span class="txt-x-small txt-ink-medium">
#<%= account.external_account_id %> •
<%= admin_user&.identity&.email_address || "No admin" %>
</span>
</div>
<div class="txt-medium">
<strong><%= number_with_delimiter(account.cards_count) %></strong>
<span class="txt-x-small txt-ink-medium">cards</span>
</div>
</li>
<% end %>
</ul>
</div>
</section>
+12 -8
View File
@@ -1,5 +1,5 @@
<% cache card do %> <section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;"> <% cache card do %>
<div class="card-perma__actions card-perma__actions--left"> <div class="card-perma__actions card-perma__actions--left">
<%= render "cards/container/gild", card: card if card.published? && !card.closed? %> <%= render "cards/container/gild", card: card if card.published? && !card.closed? %>
<%= render "cards/container/image", card: card %> <%= render "cards/container/image", card: card %>
@@ -33,11 +33,15 @@
<%= render "cards/display/preview/bubble", card: card %> <%= render "cards/display/preview/bubble", card: card %>
<% end %> <% end %>
</div> </div>
<% end %>
<% if card.published? %> <% if card.published? %>
<%= render "cards/container/footer/published", card: card %> <%= render "cards/container/footer/published", card: card %>
<% elsif card.drafted? %> <% elsif card.drafted? %>
<%= render "cards/container/footer/draft", card: card %> <% if Fizzy.saas? %>
<%= render "cards/container/footer/saas/create", card: card %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %> <% end %>
</section> <% end %>
<% end %> </section>
@@ -0,0 +1,19 @@
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="flex gap-half">
<%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn",
title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })",
form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<% end %>
<%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another",
title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %>
<span>Create and add another</span>
<% end %>
</div>
<%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %>
</div>
@@ -1,14 +0,0 @@
<div class="card-perma__notch card-perma__notch--bottom">
<%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn",
title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })",
form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<% end %>
<%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another",
title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } },
data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %>
<span>Create and add another</span>
<% end %>
</div>
+1
View File
@@ -17,6 +17,7 @@
<%= render "layouts/theme_preference" %> <%= render "layouts/theme_preference" %>
<%= stylesheet_link_tags %> <%= stylesheet_link_tags %>
<%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %>
<%= javascript_importmap_tags %> <%= javascript_importmap_tags %>
<%= tenanted_action_cable_meta_tag %> <%= tenanted_action_cable_meta_tag %>
+1 -2
View File
@@ -2,9 +2,9 @@ Rails.application.routes.draw do
root "events#index" root "events#index"
namespace :account do namespace :account do
resource :entropy
resource :join_code resource :join_code
resource :settings resource :settings
resource :entropy
resources :exports, only: [ :create, :show ] resources :exports, only: [ :create, :show ]
end end
@@ -239,6 +239,5 @@ Rails.application.routes.draw do
namespace :admin do namespace :admin do
mount MissionControl::Jobs::Engine, at: "/jobs" mount MissionControl::Jobs::Engine, at: "/jobs"
get "stats", to: "stats#show"
end end
end end
@@ -1,23 +0,0 @@
require "test_helper"
class Admin::StatsControllerTest < ActionDispatch::IntegrationTest
test "staff can access stats" do
sign_in_as :david
untenanted do
get admin_stats_url
end
assert_response :success
end
test "non-staff cannot access stats" do
sign_in_as :jz
untenanted do
get admin_stats_url
end
assert_response :forbidden
end
end
+6
View File
@@ -21,3 +21,9 @@ private_kevin:
account: 37s_uuid account: 37s_uuid
board: private_uuid board: private_uuid
user: kevin_uuid user: kevin_uuid
miltons_wish_list_mike:
id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list_mike", :uuid) %>
account: initech_uuid
board: miltons_wish_list_uuid
user: mike_uuid
+11
View File
@@ -79,3 +79,14 @@ paycheck:
status: published status: published
last_active_at: <%= 1.week.ago %> last_active_at: <%= 1.week.ago %>
account: initech_uuid account: initech_uuid
unfinished_thoughts:
id: <%= ActiveRecord::FixtureSet.identify("unfinished_thoughts", :uuid) %>
number: 3
board: miltons_wish_list_uuid
creator: mike_uuid
title: Some unfinished thoughts
created_at: <%= 1.week.ago %>
status: drafted
last_active_at: <%= 1.week.ago %>
account: initech_uuid
+1 -1
View File
@@ -81,7 +81,7 @@ class CardTest < ActiveSupport::TestCase
test "open" do test "open" do
assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts("37s").cards.open.to_set assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts("37s").cards.open.to_set
assert_equal cards(:radio, :paycheck).to_set, accounts("initech").cards.open.to_set assert_equal cards(:radio, :paycheck, :unfinished_thoughts).to_set, accounts("initech").cards.open.to_set
end end
test "card_unassigned" do test "card_unassigned" do