Skip to content
This repository was archived by the owner on Dec 16, 2025. It is now read-only.

Commit b855ebc

Browse files
Add Stripe-based billing system
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Jason Zimdars <jz@37signals.com>
1 parent 8f144d2 commit b855ebc

44 files changed

Lines changed: 1038 additions & 20 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.kamal/secrets.beta

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT)
1+
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
22

33
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
44
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)
2121
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)
2222
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
2323
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
24+
STRIPE_PRICE_ID=$(kamal secrets extract STRIPE_PRICE_ID $SECRETS)
25+
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
26+
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)

.kamal/secrets.production

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT)
1+
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
22

33
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
44
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)
2121
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)
2222
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
2323
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
24+
STRIPE_PRICE_ID=$(kamal secrets extract STRIPE_PRICE_ID $SECRETS)
25+
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
26+
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)

.kamal/secrets.staging

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT)
1+
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
22

33
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
44
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)
2121
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)
2222
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
2323
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
24+
STRIPE_PRICE_ID=$(kamal secrets extract STRIPE_PRICE_ID $SECRETS)
25+
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
26+
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/* Subscriptions
2+
/* ------------------------------------------------------------------------ */
3+
:root {
4+
--settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter)));
5+
--settings-subscription-color: oklch(var(--lch-violet-medium));
6+
--settings-subscription-text-color: oklch(var(--lch-violet-dark));
7+
8+
.settings-subscription__button {
9+
--btn-background: var(--settings-subscription-color);
10+
--btn-border-color: var(--color-canvas);
11+
--btn-color: var(--color-canvas);
12+
--focus-ring-color: var(--color-ink);
13+
}
14+
15+
.settings-subscription__divider {
16+
--divider-color: currentColor;
17+
18+
color: var(--settings-subscription-color);
19+
margin-block-start: calc(var(--block-space-half) * -1);
20+
}
21+
22+
.settings-subscription__footer {
23+
color: var(--settings-subscription-text-color);
24+
25+
&::before {
26+
content: "————";
27+
display: block;
28+
margin: auto;
29+
text-align: center;
30+
}
31+
}
32+
33+
.settings-subscription__notch {
34+
animation: wiggle 500ms ease;
35+
background: var(--settings-subscription-background);
36+
box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
37+
border-radius: 3em;
38+
color: var(--settings-subscription-text-color);
39+
margin-inline: auto;
40+
padding: 0 0.3em 0 1.2em;
41+
transform: rotate(-1deg);
42+
43+
@media (max-width: 640px) {
44+
padding-block: 0.6em;
45+
padding-inline-start: 0.3em;
46+
}
47+
48+
.btn {
49+
margin-block: 0.3em;
50+
}
51+
}
52+
53+
.settings-subscription__panel {
54+
background: var(--settings-subscription-background);
55+
}
56+
57+
.settings_subscription__warning {
58+
color: var(--settings-subscription-text-color);
59+
60+
a {
61+
color: var(--settings-subscription-text-color);
62+
}
63+
}
64+
}

app/assets/stylesheets/fizzy/saas/application.css

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
class Account::BillingPortalsController < ApplicationController
2+
before_action :ensure_admin
3+
before_action :ensure_subscribed_account
4+
5+
def show
6+
redirect_to create_stripe_billing_portal_session.url, allow_other_host: true
7+
end
8+
9+
private
10+
def ensure_subscribed_account
11+
unless Current.account.subscribed?
12+
redirect_to account_subscription_path, alert: "No billing information found"
13+
end
14+
end
15+
16+
def create_stripe_billing_portal_session
17+
Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url)
18+
end
19+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
class Account::SubscriptionsController < ApplicationController
2+
before_action :ensure_admin
3+
before_action :set_stripe_session, only: :show
4+
5+
def show
6+
end
7+
8+
def create
9+
session = Stripe::Checkout::Session.create \
10+
customer: find_or_create_stripe_customer,
11+
mode: "subscription",
12+
line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ],
13+
success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
14+
cancel_url: account_subscription_url,
15+
metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }
16+
17+
redirect_to session.url, allow_other_host: true
18+
end
19+
20+
private
21+
def set_stripe_session
22+
@session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id]
23+
end
24+
25+
def find_or_create_stripe_customer
26+
find_stripe_customer || create_stripe_customer
27+
end
28+
29+
def find_stripe_customer
30+
Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id
31+
end
32+
33+
def create_stripe_customer
34+
Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer|
35+
Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete")
36+
end
37+
end
38+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Admin::AccountSearchesController < AdminController
2+
def create
3+
redirect_to saas.edit_admin_account_path(params[:q])
4+
end
5+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
class Admin::AccountsController < AdminController
2+
layout "public"
3+
4+
before_action :set_account, only: %i[ edit update ]
5+
6+
def index
7+
end
8+
9+
def edit
10+
end
11+
12+
def update
13+
@account.update!(account_params)
14+
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated"
15+
end
16+
17+
private
18+
def set_account
19+
@account = Account.find_by!(external_account_id: params[:id])
20+
end
21+
22+
def account_params
23+
params.expect(account: [ :cards_count ])
24+
end
25+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
class Admin::StatsController < AdminController
2+
layout "public"
3+
4+
def show
5+
@accounts_total = Account.count
6+
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
7+
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
8+
9+
@identities_total = Identity.count
10+
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
11+
@identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count
12+
13+
@top_accounts = Account
14+
.where("cards_count > 0")
15+
.order(cards_count: :desc)
16+
.limit(20)
17+
18+
@recent_accounts = Account.order(created_at: :desc).limit(10)
19+
end
20+
end

0 commit comments

Comments
 (0)