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

Commit 775fafc

Browse files
committed
Add Stripe-based billing system
1 parent 43dbc89 commit 775fafc

38 files changed

Lines changed: 1014 additions & 17 deletions
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
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module Card::Limited
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
before_action :ensure_can_create_cards, only: %i[ create ]
6+
end
7+
8+
private
9+
def ensure_can_create_cards
10+
head :forbidden if Current.account.exceeding_card_limit?
11+
end
12+
end
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
class Stripe::WebhooksController < ApplicationController
2+
allow_unauthenticated_access
3+
skip_before_action :require_account
4+
skip_before_action :verify_authenticity_token
5+
6+
def create
7+
event = verify_webhook_signature
8+
return head :bad_request unless event
9+
10+
dispatch_stripe_event(event)
11+
12+
head :ok
13+
end
14+
15+
private
16+
def dispatch_stripe_event(event)
17+
case event.type
18+
when "checkout.session.completed"
19+
handle_checkout_completed(event.data.object)
20+
when "customer.subscription.updated"
21+
handle_subscription_updated(event.data.object)
22+
when "customer.subscription.deleted"
23+
handle_subscription_deleted(event.data.object)
24+
when "invoice.payment_failed"
25+
handle_payment_failed(event.data.object)
26+
end
27+
end
28+
29+
def verify_webhook_signature
30+
payload = request.body.read
31+
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
32+
33+
Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
34+
rescue Stripe::SignatureVerificationError => e
35+
Rails.logger.error "Stripe webhook signature verification failed: #{e.message}"
36+
nil
37+
end
38+
39+
def handle_checkout_completed(session)
40+
return unless session.mode == "subscription"
41+
42+
subscription = find_subscription_by_customer(session.customer)
43+
return unless subscription
44+
45+
stripe_subscription = Stripe::Subscription.retrieve(session.subscription)
46+
47+
subscription.update! \
48+
stripe_subscription_id: stripe_subscription.id,
49+
plan_key: session.metadata["plan_key"],
50+
status: stripe_subscription.status,
51+
current_period_end: extract_current_period_end(stripe_subscription)
52+
end
53+
54+
def handle_subscription_updated(stripe_subscription)
55+
if subscription = find_subscription_by_customer(stripe_subscription.customer)
56+
subscription.update! \
57+
status: stripe_subscription.status,
58+
current_period_end: extract_current_period_end(stripe_subscription),
59+
cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil
60+
end
61+
end
62+
63+
def handle_subscription_deleted(stripe_subscription)
64+
if subscription = find_subscription_by_customer(stripe_subscription.customer)
65+
subscription.update!(status: "canceled", stripe_subscription_id: nil)
66+
end
67+
end
68+
69+
def handle_payment_failed(invoice)
70+
if subscription = find_subscription_by_customer(invoice.customer)
71+
subscription.update!(status: "past_due")
72+
end
73+
end
74+
75+
def find_subscription_by_customer(customer_id)
76+
Account::Subscription.find_by(stripe_customer_id: customer_id)
77+
end
78+
79+
def extract_current_period_end(stripe_subscription)
80+
timestamp = stripe_subscription.items.data.first&.current_period_end
81+
Time.at(timestamp) if timestamp
82+
end
83+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module SubscriptionsHelper
2+
def subscription_period_end_action(subscription)
3+
if subscription.to_be_canceled?
4+
"Your Fizzy subscription ends on"
5+
elsif subscription.canceled?
6+
"Your Fizzy subscription ended on"
7+
else
8+
"Your next payment of <b>$#{ subscription.plan.price }</b> will be billed on".html_safe
9+
end
10+
end
11+
end

0 commit comments

Comments
 (0)