Merge pull request #23 from basecamp/billing

Add monthly subscriptions
This commit is contained in:
Jorge Manrubia
2025-12-16 16:27:41 +01:00
committed by GitHub
61 changed files with 1485 additions and 28 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
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)
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_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+4 -1
View File
@@ -1,4 +1,4 @@
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)
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_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+4 -1
View File
@@ -1,4 +1,4 @@
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)
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_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
@@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS)
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS)
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS)
STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+22
View File
@@ -24,6 +24,28 @@ After making changes to this gem, you need to update Fizzy to pick up the change
BUNDLE_GEMFILE=Gemfile.saas bundle update --conservative fizzy-saas
```
## Working with Stripe
The first time, you need to:
1. Install Stripe CLI: https://stripe.com/docs/stripe-cli
2. Run `stripe login` and authorize the environment `37signals Development`
Then, for working on the Stripe integration locally, you need to run this script to start the tunneling and set the environment variables:
```sh
eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec stripe-dev)"
bin/dev # You need to start the dev server in the same terminal session
```
This will ask for your 1password authorization to read and set the environment variables that Stripe needs.
### Stripe environments
* [Development](https://dashboard.stripe.com/acct_1SdTFtRus34tgjsJ/test/dashboard)
* [Staging](https://dashboard.stripe.com/acct_1SdTbuRvb8txnPBR/test/dashboard)
* [Production](https://dashboard.stripe.com/acct_1SNy97RwChFE4it8/dashboard)
## Environments
Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment.
@@ -0,0 +1,64 @@
/* Subscriptions
/* ------------------------------------------------------------------------ */
:root {
--settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter)));
--settings-subscription-color: oklch(var(--lch-violet-medium));
--settings-subscription-text-color: oklch(var(--lch-violet-dark));
.settings-subscription__button {
--btn-background: var(--settings-subscription-color);
--btn-border-color: var(--color-canvas);
--btn-color: var(--color-canvas);
--focus-ring-color: var(--color-ink);
}
.settings-subscription__divider {
--divider-color: currentColor;
color: var(--settings-subscription-color);
margin-block-start: calc(var(--block-space-half) * -1);
}
.settings-subscription__footer {
color: var(--settings-subscription-text-color);
&::before {
content: "————";
display: block;
margin: auto;
text-align: center;
}
}
.settings-subscription__notch {
animation: wiggle 500ms ease;
background: var(--settings-subscription-background);
box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
border-radius: 3em;
color: var(--settings-subscription-text-color);
margin-inline: auto;
padding: 0 0.3em 0 1.2em;
transform: rotate(-1deg);
@media (max-width: 640px) {
padding-block: 0.6em;
padding-inline-start: 0.3em;
}
.btn {
margin-block: 0.3em;
}
}
.settings-subscription__panel {
background: var(--settings-subscription-background);
}
.settings_subscription__warning {
color: var(--settings-subscription-text-color);
a {
color: var(--settings-subscription-text-color);
}
}
}
@@ -1,15 +0,0 @@
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require_tree .
*= require_self
*/
@@ -0,0 +1,19 @@
class Account::BillingPortalsController < ApplicationController
before_action :ensure_admin
before_action :ensure_subscribed_account
def show
redirect_to create_stripe_billing_portal_session.url, allow_other_host: true
end
private
def ensure_subscribed_account
unless Current.account.subscribed?
redirect_to account_subscription_path, alert: "No billing information found"
end
end
def create_stripe_billing_portal_session
Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url)
end
end
@@ -0,0 +1,42 @@
class Account::SubscriptionsController < ApplicationController
before_action :ensure_admin
before_action :set_stripe_session, only: :show
def show
end
def create
session = Stripe::Checkout::Session.create \
customer: find_or_create_stripe_customer,
mode: "subscription",
line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ],
success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: account_subscription_url,
metadata: { account_id: Current.account.id, plan_key: Plan.paid.key },
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: "required",
customer_update: { address: "auto", name: "auto" }
redirect_to session.url, allow_other_host: true
end
private
def set_stripe_session
@stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id]
end
def find_or_create_stripe_customer
find_stripe_customer || create_stripe_customer
end
def find_stripe_customer
Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id
end
def create_stripe_customer
Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer|
Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete")
end
end
end
@@ -0,0 +1,5 @@
class Admin::AccountSearchesController < AdminController
def create
redirect_to saas.edit_admin_account_path(params[:q])
end
end
@@ -0,0 +1,27 @@
class Admin::AccountsController < AdminController
include Admin::AccountScoped
layout "public"
before_action :set_account, only: %i[ edit update ]
def index
end
def edit
end
def update
@account.override_limits(card_count: overridden_card_count_param)
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated"
end
private
def set_account
@account = Account.find_by!(external_account_id: params[:id])
end
def overridden_card_count_param
params[:account][:overridden_card_count].to_i
end
end
@@ -0,0 +1,13 @@
class Admin::BillingWaiversController < AdminController
include Admin::AccountScoped
def create
@account.comp
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account comped"
end
def destroy
@account.uncomp
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped"
end
end
@@ -0,0 +1,8 @@
class Admin::OverriddenLimitsController < AdminController
include Admin::AccountScoped
def destroy
@account.reset_overridden_limits
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset"
end
end
@@ -0,0 +1,20 @@
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
@@ -0,0 +1,12 @@
module Admin::AccountScoped
extend ActiveSupport::Concern
included do
before_action :set_account
end
private
def set_account
@account = Account.find_by!(external_account_id: params[:account_id] || params[:id])
end
end
@@ -0,0 +1,14 @@
module Card::LimitedCreation
extend ActiveSupport::Concern
included do
# Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count.
# We limit card publications separately. See +Card::LimitedPublishing+.
before_action :ensure_can_create_cards, only: %i[ create ], if: -> { request.format.json? }
end
private
def ensure_can_create_cards
head :forbidden if Current.account.exceeding_card_limit?
end
end
@@ -0,0 +1,12 @@
module Card::LimitedPublishing
extend ActiveSupport::Concern
included do
before_action :ensure_can_publish_cards, only: %i[ create ]
end
private
def ensure_can_publish_cards
head :forbidden if Current.account.exceeding_card_limit?
end
end
@@ -0,0 +1,79 @@
class Stripe::WebhooksController < ApplicationController
allow_unauthenticated_access
skip_before_action :require_account
skip_before_action :verify_authenticity_token
def create
if event = verify_webhook_signature
dispatch_stripe_event(event)
head :ok
else
head :bad_request
end
end
private
def dispatch_stripe_event(event)
case event.type
when "checkout.session.completed"
sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription"
when "customer.subscription.updated", "customer.subscription.deleted"
sync_subscription(event.data.object.id)
end
end
def verify_webhook_signature
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
rescue Stripe::SignatureVerificationError => e
Rails.logger.error "Stripe webhook signature verification failed: #{e.message}"
nil
end
def sync_new_subscription(stripe_subscription_id, plan_key:)
sync_subscription(stripe_subscription_id) do |subscription_properties|
subscription_properties[:plan_key] = plan_key if plan_key
end
end
# Always fetch fresh subscription data from Stripe to handle out-of-order
# event delivery. Not relying on payload data.
def sync_subscription(stripe_subscription_id)
stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id)
if subscription = find_subscription_by_stripe_customer(stripe_subscription.customer)
subscription_properties = {
stripe_subscription_id: stripe_subscription.id,
status: stripe_subscription.status,
current_period_end: current_period_end_for(stripe_subscription),
cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil,
next_amount_due_in_cents: next_amount_due_for(stripe_subscription)
}
yield subscription_properties if block_given?
subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled"
subscription.update!(subscription_properties)
end
end
def find_subscription_by_stripe_customer(id)
Account::Subscription.find_by(stripe_customer_id: id)
end
def current_period_end_for(stripe_subscription)
timestamp = stripe_subscription.items.data.first&.current_period_end
Time.at(timestamp) if timestamp
end
def next_amount_due_for(stripe_subscription)
return nil if stripe_subscription.status == "canceled"
preview = Stripe::Invoice.create_preview(customer: stripe_subscription.customer, subscription: stripe_subscription.id)
preview.amount_due
rescue Stripe::InvalidRequestError
nil
end
end
+15
View File
@@ -0,0 +1,15 @@
module SubscriptionsHelper
def plan_storage_limit(plan)
number_to_human_size(plan.storage_limit).delete(" ")
end
def subscription_period_end_action(subscription)
if subscription.to_be_canceled?
"Your Fizzy subscription ends on"
elsif subscription.canceled?
"Your Fizzy subscription ended on"
else
"Your next payment of <b>#{ number_to_currency(subscription.next_amount_due) }</b> will be billed on".html_safe
end
end
end
+42
View File
@@ -0,0 +1,42 @@
module Account::Billing
extend ActiveSupport::Concern
included do
has_one :subscription, class_name: "Account::Subscription", dependent: :destroy
has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy
end
def plan
active_subscription&.plan || Plan.free
end
def subscribed?
subscription.present?
end
def comped?
billing_waiver.present?
end
def comp
create_billing_waiver unless billing_waiver
end
def uncomp
billing_waiver&.destroy
reload_billing_waiver
end
private
def active_subscription
if comped?
comped_subscription
elsif subscription&.active?
subscription
end
end
def comped_subscription
@comped_subscription ||= billing_waiver&.subscription
end
end
@@ -0,0 +1,7 @@
class Account::BillingWaiver < SaasRecord
belongs_to :account
def subscription
@subscription ||= Account::Subscription.new(plan_key: Plan.paid.key)
end
end
+35
View File
@@ -0,0 +1,35 @@
module Account::Limited
extend ActiveSupport::Concern
included do
has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy
end
NEAR_CARD_LIMIT_THRESHOLD = 100
def override_limits(card_count:)
(overridden_limits || build_overridden_limits).update!(card_count:)
end
def billed_cards_count
overridden_limits&.card_count || cards_count
end
def nearing_plan_cards_limit?
plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD
end
def exceeding_card_limit?
plan.limit_cards? && billed_cards_count > plan.card_limit
end
def reset_overridden_limits
overridden_limits&.destroy
reload_overridden_limits
end
private
def remaining_cards_count
plan.card_limit - billed_cards_count
end
end
@@ -0,0 +1,4 @@
# To ease testing of limits
class Account::OverriddenLimits < SaasRecord
belongs_to :account
end
+21
View File
@@ -0,0 +1,21 @@
class Account::Subscription < SaasRecord
belongs_to :account
enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
delegate :paid?, to: :plan
def plan
@plan ||= Plan.find(plan_key)
end
def to_be_canceled?
active? && cancel_at.present?
end
def next_amount_due
next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price
end
end
+50
View File
@@ -0,0 +1,50 @@
class Plan
PLANS = {
free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes },
monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }
}
attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id
class << self
def all
@all ||= PLANS.map { |key, properties| new(key: key, **properties) }
end
def free
@free ||= find(:free_v1)
end
def paid
@paid ||= find(:monthly_v1)
end
def find(key)
@all_by_key ||= all.index_by(&:key).with_indifferent_access
@all_by_key[key]
end
alias [] find
end
def initialize(key:, name:, price:, card_limit:, storage_limit:, stripe_price_id: nil)
@key = key
@name = name
@price = price
@card_limit = card_limit
@storage_limit = storage_limit
@stripe_price_id = stripe_price_id
end
def free?
price.zero?
end
def paid?
!free?
end
def limit_cards?
card_limit != Float::INFINITY
end
end
@@ -0,0 +1,7 @@
<p hidden>Status: <strong><%= subscription.status.titleize %></strong></p>
<% if subscription.current_period_end %>
<p class="txt-medium margin-none-block-start"><%= subscription_period_end_action(subscription) %> <strong><%= subscription.current_period_end.to_date.to_fs(:long) %></strong></p>
<% end %>
<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %>
@@ -0,0 +1,28 @@
<% if Current.user.admin? && !Current.account.comped? %>
<section class="panel panel--wide settings-subscription__panel shadow center margin-block-double">
<h2 id="subscription" class="divider settings-subscription__divider txt-small txt-uppercase margin-none">Subscription</h2>
<% if Current.account.plan.free? %>
<% if Current.account.exceeding_card_limit? %>
<h3 class="margin-block-start margin-block-end-half txt-large font-weight-black">Youve used up your <u><%= Plan.free.card_limit %></u> free cards</h3>
<% else %>
<h3 class="margin-block-start margin-block-end-half txt-large font-weight-black">Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= Plan.free.card_limit %></h3>
<% end %>
<p class="margin-none-block-start">If youd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, its only <strong>$<%= Plan.paid.price %>/month</strong> for <strong>unlimited cards</strong> + <strong>unlimited users</strong>. You'll also get <strong><%= plan_storage_limit(Plan.paid) %></strong> of storage. </p>
<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<p>Cancel anytime, no contracts, take your data with you whenever.</p>
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= Plan.free.card_limit %> cards, unlimited users, and <%= plan_storage_limit(Plan.free) %> of storage.</p>
<% else %>
<h3 class="margin-block-start margin-block-end-half txt-x-large font-weight-black">Thank you for buying Fizzy</h3>
<% if Current.account.subscription %>
<%= render "account/settings/subscription", subscription: Current.account.subscription %>
<p class="settings-subscription__footer txt-small">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= plan_storage_limit(Plan.paid) %> of storage.</p>
<% end %>
<% end %>
</section>
<% end %>
@@ -0,0 +1,12 @@
<div class="settings-subscription__notch card-perma__notch card-perma__notch--bottom">
<% if Current.user.admin? %>
<strong>Youve used your <u><%= Plan.free.card_limit %></u> free cards.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to Unlimited</span>
<% end %>
<% else %>
<div class="pad-inline pad-block-half">
<strong>This account has used <u><%= Plan.free.card_limit %></u> free cards. Upgrade to get more.</strong>
</div>
<% end %>
</div>
@@ -0,0 +1,9 @@
<% @page_title = "Thank you" %>
<% if @stripe_session&.payment_status == "paid" %>
<section class="panel panel--wide panel--centered center borderless">
<h1 class="txt-x-large font-weight-black margin-none">Thanks for buying Fizzy!</h1>
<p class="txt-medium margin-none-block-start">Your payment was successful. Youre now on the <strong><%= Current.account.plan.name %></strong> plan.</p>
<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
</section>
<% end %>
@@ -0,0 +1,39 @@
<div class="panel shadow center" style="--panel-size: 50ch;">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-block-none">Edit Account <%= @account.external_account_id %></h1>
<dl class="flex flex-column gap-half margin-block-none">
<div class="flex gap-half">
<dt class="font-weight-bold">Name:</dt>
<dd class="margin-inline-start-none"><%= @account.name %></dd>
</div>
<div class="flex gap-half">
<dt class="font-weight-bold">Actual card count:</dt>
<dd class="margin-inline-start-none"><%= @account.cards_count %></dd>
</div>
</dl>
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
<div class="flex flex-column gap-half">
<%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %>
<div class="flex align-center gap input input--actor">
<%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %>
</div>
</div>
<button type="submit" class="btn btn--reversed">Save</button>
<% end %>
<% if @account.overridden_limits %>
<%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %>
<% end %>
<% if @account.comped? %>
<%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %>
<% else %>
<%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--positive" %>
<% end %>
<%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %>
</div>
</div>
@@ -0,0 +1,6 @@
<h1>Find Account</h1>
<%= form_with url: saas.admin_account_search_path do |form| %>
<%= form.text_field :q, placeholder: "Account ID", autofocus: true %>
<%= form.submit "Search" %>
<% end %>
+126
View File
@@ -0,0 +1,126 @@
<% @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>
@@ -0,0 +1,5 @@
<% if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/upgrade" %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
@@ -0,0 +1,9 @@
<% if Current.account.nearing_plan_cards_limit? %>
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
<% if Current.user.admin? %>
Youve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong><%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %></strong>.
<% else %>
This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong>Upgrade soon</strong>
<% end %>
</div>
<% end %>
+3
View File
@@ -44,6 +44,9 @@ env:
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
sc_chi: {}
df_iad:
+3
View File
@@ -50,6 +50,9 @@ env:
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
sc_chi:
MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com
+3
View File
@@ -50,6 +50,9 @@ env:
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
sc_chi:
MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com
+6
View File
@@ -3,5 +3,11 @@ Fizzy::Saas::Engine.routes.draw do
namespace :admin do
mount Audits1984::Engine, at: "/console"
get "stats", to: "stats#show"
resource :account_search, only: :create
resources :accounts do
resource :overridden_limits, only: :destroy
resource :billing_waiver, only: [ :create, :destroy ]
end
end
end
@@ -0,0 +1,15 @@
class CreateAccountSubscriptions < ActiveRecord::Migration[8.2]
def change
create_table :account_subscriptions, id: :uuid do |t|
t.references :account, null: false, type: :uuid, index: true
t.string :plan_key
t.string :stripe_customer_id, null: false, index: { unique: true }
t.string :stripe_subscription_id, index: { unique: true }
t.string :status
t.datetime :current_period_end
t.datetime :cancel_at
t.timestamps
end
end
end
@@ -0,0 +1,10 @@
class CreateAccountOverriddenLimits < ActiveRecord::Migration[8.2]
def change
create_table :account_overridden_limits, id: :uuid do |t|
t.references :account, null: false, type: :uuid, index: { unique: true }
t.integer :card_count
t.timestamps
end
end
end
@@ -0,0 +1,9 @@
class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2]
def change
create_table :account_billing_waivers, id: :uuid do |t|
t.references :account, null: false, type: :uuid, index: { unique: true }
t.timestamps
end
end
end
@@ -0,0 +1,5 @@
class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2]
def change
add_column :account_subscriptions, :next_amount_due_in_cents, :integer
end
end
+32 -1
View File
@@ -10,7 +10,38 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_02_205753) do
ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do
create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_billing_waivers_on_account_id", unique: true
end
create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.integer "card_count"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_overridden_limits_on_account_id", unique: true
end
create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "cancel_at"
t.datetime "created_at", null: false
t.datetime "current_period_end"
t.integer "next_amount_due_in_cents"
t.string "plan_key"
t.string "status"
t.string "stripe_customer_id", null: false
t.string "stripe_subscription_id"
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_subscriptions_on_account_id"
t.index ["stripe_customer_id"], name: "index_account_subscriptions_on_stripe_customer_id", unique: true
t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true
end
create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "auditor_id", null: false
t.datetime "created_at", null: false
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env ruby
#
# Fetches Stripe development environment variables from 1Password and starts
# the Stripe CLI webhook listener.
#
# Usage: eval "$(bundle exec stripe-dev)"
require "json"
require "fileutils"
LOG_FILE = "log/stripe.development.log"
PID_FILE = "tmp/stripe.tunnel.pid"
# Ensure directories exist
FileUtils.mkdir_p("log")
FileUtils.mkdir_p("tmp")
# Kill any existing stripe tunnel process
if File.exist?(PID_FILE)
old_pid = File.read(PID_FILE).strip.to_i
if old_pid > 0
Process.kill("TERM", old_pid) rescue nil
end
File.delete(PID_FILE)
end
# Fetch secrets from 1Password
secrets_escaped = `kamal secrets fetch \
--adapter 1password \
--account basecamp \
--from "Deploy/Fizzy" \
"Development/STRIPE_SECRET_KEY" \
"Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null`
secrets_json = secrets_escaped.gsub(/\\(.)/, '\1')
secrets = JSON.parse(secrets_json)
stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"]
stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"]
# Clear previous log file
File.write(LOG_FILE, "")
# Start stripe listen in background
pid = spawn(
"stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks",
out: [ LOG_FILE, "a" ],
err: [ LOG_FILE, "a" ]
)
Process.detach(pid)
# Save PID for cleanup
File.write(PID_FILE, pid.to_s)
# Wait for the webhook secret to appear in logs
webhook_secret = nil
20.times do
sleep 0.5
if File.exist?(LOG_FILE)
content = File.read(LOG_FILE)
if match = content.match(/webhook signing secret is (whsec_\w+)/)
webhook_secret = match[1]
break
end
end
end
if webhook_secret.nil?
warn "Warning: Could not capture webhook secret from stripe listen"
end
# Output export statements
puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}")
puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}")
puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret
# Informational message to stderr (won't be eval'd)
warn ""
warn "Stripe CLI listening (PID: #{pid})"
warn "Logs: #{LOG_FILE}"
+4 -1
View File
@@ -18,9 +18,12 @@ Gem::Specification.new do |spec|
spec.metadata["source_code_uri"] = "https://github.com/basecamp/fizzy-saas"
spec.files = Dir.chdir(File.expand_path(__dir__)) do
Dir["{app,config,db,lib,test}/**/*", "LICENSE.md", "Rakefile", "README.md"]
Dir["{app,config,db,lib,exe}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"]
end
spec.bindir = "exe"
spec.executables = [ "stripe-dev" ]
spec.add_dependency "rails", ">= 8.1.0.beta1"
spec.add_dependency "queenbee"
spec.add_dependency "rails_structured_logging"
+30 -1
View File
@@ -9,6 +9,28 @@ module Fizzy
# moved from config/initializers/queenbee.rb
Queenbee.host_app = Fizzy
initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com"
end
initializer "fizzy_saas.assets" do |app|
app.config.assets.paths << root.join("app/assets/stylesheets")
end
initializer "fizzy.saas.routes", after: :add_routing_paths do |app|
# Routes that rely on the implicit account tenant should go here instead of in +routes.rb+.
app.routes.prepend do
namespace :account do
resource :billing_portal, only: :show
resource :subscription
end
namespace :stripe do
resource :webhooks, only: :create
end
end
end
initializer "fizzy.saas.mount" do |app|
app.routes.append do
mount Fizzy::Saas::Engine => "/", as: "saas"
@@ -47,6 +69,10 @@ module Fizzy
end
end
initializer "fizzy_saas.stripe" do
Stripe.api_key = ENV["STRIPE_SECRET_KEY"]
end
initializer "fizzy_saas.sentry" do
if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank?
Sentry.init do |config|
@@ -101,7 +127,10 @@ module Fizzy
end
config.to_prepare do
::Signup.prepend(Fizzy::Saas::Signup)
::Account.include Account::Billing, Account::Limited
::Signup.prepend Fizzy::Saas::Signup
CardsController.include(Card::LimitedCreation)
Cards::PublishesController.include(Card::LimitedPublishing)
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
+11
View File
@@ -7,3 +7,14 @@ Queenbee::Remote::Account.class_eval do
super + Random.rand(1000000)
end
end
# Add engine fixtures to the test fixture paths
module Fizzy::Saas::EngineFixtures
def included(base)
super
engine_fixtures = Fizzy::Saas::Engine.root.join("test", "fixtures").to_s
base.fixture_paths << engine_fixtures unless base.fixture_paths.include?(engine_fixtures)
end
end
ActiveRecord::TestFixtures.singleton_class.prepend(Fizzy::Saas::EngineFixtures)
-7
View File
@@ -1,13 +1,6 @@
require "rake/testtask"
namespace :test do
# task :prepare_saas => :environment do
# require "rails/test_help"
#
# $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s
# require Fizzy::Saas::Engine.root.join("test/test_helper")
# end
desc "Run tests for fizzy-saas gem"
Rake::TestTask.new(saas: :environment) do |t|
t.libs << "test"
@@ -0,0 +1,29 @@
require "test_helper"
require "ostruct"
class Account::BillingPortalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "redirects to stripe billing portal" do
Current.account.subscription.update!(stripe_customer_id: "cus_test123")
session = OpenStruct.new(url: "https://billing.stripe.com/session123")
Stripe::BillingPortal::Session.expects(:create)
.with(customer: "cus_test123", return_url: account_settings_url)
.returns(session)
get account_billing_portal_path
assert_redirected_to "https://billing.stripe.com/session123"
end
test "requires admin" do
logout_and_sign_in_as :david
get account_billing_portal_path
assert_response :forbidden
end
end
@@ -0,0 +1,46 @@
require "test_helper"
require "ostruct"
class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "show" do
get account_subscription_path
assert_response :success
end
test "show with session_id retrieves stripe session" do
Stripe::Checkout::Session.stubs(:retrieve).with("sess_123").returns(OpenStruct.new(id: "sess_123"))
get account_subscription_path(session_id: "sess_123")
assert_response :success
end
test "show requires admin" do
logout_and_sign_in_as :david
get account_subscription_path
assert_response :forbidden
end
test "create redirects to stripe checkout" do
customer = OpenStruct.new(id: "cus_test_37signals")
session = OpenStruct.new(url: "https://checkout.stripe.com/session123")
Stripe::Customer.stubs(:retrieve).returns(customer)
Stripe::Checkout::Session.stubs(:create).returns(session)
post account_subscription_path
assert_redirected_to "https://checkout.stripe.com/session123"
end
test "create requires admin" do
logout_and_sign_in_as :david
post account_subscription_path
assert_response :forbidden
end
end
@@ -0,0 +1,54 @@
require "test_helper"
class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
test "staff can access index" do
sign_in_as :david
untenanted do
get saas.admin_accounts_path
end
assert_response :success
end
test "search account" do
sign_in_as :david
untenanted do
post saas.admin_account_search_path, params: { q: accounts(:"37s").external_account_id }
assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
end
end
test "staff can edit account" do
sign_in_as :david
untenanted do
get saas.edit_admin_account_path(accounts(:"37s").external_account_id)
end
assert_response :success
end
test "staff can override card count" do
sign_in_as :david
untenanted do
patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } }
assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
end
assert_equal 500, accounts(:"37s").reload.billed_cards_count
end
test "non-staff cannot access accounts" do
sign_in_as :jz
untenanted do
patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 9999 } }
end
assert_response :forbidden
assert_not_equal 9999, accounts(:"37s").reload.cards_count
end
end
@@ -0,0 +1,32 @@
require "test_helper"
class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest
test "staff can comp an account" do
sign_in_as :david
account = accounts(:"37s")
assert_not account.comped?
untenanted do
post saas.admin_account_billing_waiver_path(account.external_account_id)
assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
end
assert account.reload.comped?
end
test "staff can uncomp an account" do
sign_in_as :david
account = accounts(:"37s")
account.comp
assert account.comped?
untenanted do
delete saas.admin_account_billing_waiver_path(account.external_account_id)
assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
end
assert_not account.reload.comped?
end
end
@@ -0,0 +1,22 @@
require "test_helper"
class Admin::OverriddenLimitsControllerTest < ActionDispatch::IntegrationTest
test "staff can reset overridden limits" do
sign_in_as :david
account = accounts(:"37s")
# First set an override
account.override_limits(card_count: 500)
assert_equal 500, account.reload.billed_cards_count
# Then reset it
untenanted do
delete saas.admin_account_overridden_limits_path(account.external_account_id)
assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
end
# Verify override was removed
assert_nil account.reload.overridden_limits
assert_equal account.cards_count, account.billed_cards_count
end
end
@@ -0,0 +1,23 @@
require "test_helper"
class Admin::StatsControllerTest < ActionDispatch::IntegrationTest
test "staff can access stats" do
sign_in_as :david
untenanted do
get saas.admin_stats_path
end
assert_response :success
end
test "non-staff cannot access stats" do
sign_in_as :jz
untenanted do
get saas.admin_stats_path
end
assert_response :forbidden
end
end
@@ -0,0 +1,43 @@
require "test_helper"
class Card::LimitedCreationTest < ActionDispatch::IntegrationTest
test "cannot create cards via JSON when card limit exceeded" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 1001)
assert_no_difference -> { Card.count } do
post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json)
end
assert_response :forbidden
end
test "can create cards via HTML when card limit exceeded but they are drafts" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 1001)
boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all
assert_difference -> { Card.count } do
post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug)
end
assert_response :redirect
assert Card.last.drafted?
end
test "cannot force published status via HTML when card limit exceeded" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 1001)
boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all
assert_difference -> { Card.count } do
post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { status: "published" } }
end
assert_response :redirect
assert Card.last.drafted?
end
end
@@ -0,0 +1,14 @@
require "test_helper"
class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest
test "cannot publish cards when card limit exceeded" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 1001)
post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :forbidden
assert cards(:unfinished_thoughts).reload.drafted?
end
end
@@ -0,0 +1,100 @@
require "test_helper"
require "ostruct"
class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
setup do
@account = Account.create!(name: "Test")
@subscription = @account.create_subscription! \
plan_key: "monthly_v1",
status: "incomplete",
stripe_customer_id: "cus_test123"
end
test "invalid signature returns bad request" do
Stripe::Webhook.stubs(:construct_event).raises(Stripe::SignatureVerificationError.new("invalid", "sig"))
post stripe_webhooks_path
assert_response :bad_request
end
test "checkout session completed activates subscription" do
stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i))
event = stripe_event("checkout.session.completed",
mode: "subscription",
customer: "cus_test123",
subscription: "sub_123",
metadata: { "plan_key" => "monthly_v1" }
)
Stripe::Webhook.stubs(:construct_event).returns(event)
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
post stripe_webhooks_path
assert_response :ok
@subscription.reload
assert_equal "sub_123", @subscription.stripe_subscription_id
assert_equal "active", @subscription.status
end
test "subscription updated changes status and syncs next amount due" do
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
stripe_sub = OpenStruct.new(
id: "sub_123",
customer: "cus_test123",
status: "past_due",
cancel_at: nil,
items: stub_items(1.month.from_now.to_i)
)
event = stripe_event("customer.subscription.updated", id: "sub_123")
Stripe::Webhook.stubs(:construct_event).returns(event)
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
post stripe_webhooks_path
assert_response :ok
@subscription.reload
assert_equal "past_due", @subscription.status
assert_equal 1999, @subscription.next_amount_due_in_cents
end
test "subscription deleted cancels subscription" do
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
stripe_sub = OpenStruct.new(
id: "sub_123",
customer: "cus_test123",
status: "canceled",
cancel_at: nil,
items: stub_items(1.month.from_now.to_i)
)
event = stripe_event("customer.subscription.deleted", id: "sub_123")
Stripe::Webhook.stubs(:construct_event).returns(event)
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
post stripe_webhooks_path
assert_response :ok
@subscription.reload
assert_equal "canceled", @subscription.status
assert_nil @subscription.stripe_subscription_id
assert_nil @subscription.next_amount_due_in_cents
end
private
def stripe_event(type, **attributes)
OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes)))
end
def stub_items(current_period_end)
OpenStruct.new(data: [ OpenStruct.new(current_period_end: current_period_end) ])
end
end
+11
View File
@@ -0,0 +1,11 @@
_fixture:
model_class: Account::Subscription
signals_monthly:
id: <%= ActiveRecord::FixtureSet.identify("signals_monthly", :uuid) %>
account_id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %>
plan_key: monthly_v1
status: active
stripe_customer_id: cus_test_37signals
created_at: <%= Time.current %>
updated_at: <%= Time.current %>
+31
View File
@@ -0,0 +1,31 @@
require "test_helper"
class Account::BillingTest < ActiveSupport::TestCase
test "plan reflects active subscription" do
account = accounts(:initech)
# No subscription
assert_equal Plan.free, account.plan
# Subscription but it is not active
account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test")
assert_equal Plan.free, account.plan
# Active subscription exists
account.subscription.update!(status: "active")
assert_equal Plan.paid, account.plan
end
test "comped account" do
account = accounts(:"37s")
assert_not account.comped?
account.comp
assert account.comped?
# Calling comp again does not create duplicate
account.comp
assert_equal 1, Account::BillingWaiver.where(account: account).count
end
end
+76
View File
@@ -0,0 +1,76 @@
require "test_helper"
class Account::LimitedTest < ActiveSupport::TestCase
test "detect nearing card limit" do
# Paid plans are never limited
accounts(:"37s").update_column(:cards_count, 1_000_000)
assert_not accounts(:"37s").nearing_plan_cards_limit?
# Free plan not near limit
accounts(:initech).update_column(:cards_count, 899)
assert_not accounts(:initech).nearing_plan_cards_limit?
# Free plan near limit
accounts(:initech).update_column(:cards_count, 900)
assert_not accounts(:initech).nearing_plan_cards_limit?
accounts(:initech).update_column(:cards_count, 901)
assert accounts(:initech).nearing_plan_cards_limit?
end
test "detect exceeding card limit" do
# Paid plans are never limited
accounts(:"37s").update_column(:cards_count, 1_000_000)
assert_not accounts(:"37s").exceeding_card_limit?
# Free plan under limit
accounts(:initech).update_column(:cards_count, 999)
assert_not accounts(:initech).exceeding_card_limit?
# Free plan over limit
accounts(:initech).update_column(:cards_count, 1001)
assert accounts(:initech).exceeding_card_limit?
end
test "override limits" do
account = accounts(:initech)
account.update_column(:cards_count, 1001)
assert account.exceeding_card_limit?
assert_equal 1001, account.billed_cards_count
account.override_limits card_count: 500
assert_not account.exceeding_card_limit?
assert_equal 500, account.billed_cards_count
assert_equal 1001, account.cards_count # original unchanged
account.reset_overridden_limits
assert account.exceeding_card_limit?
assert_equal 1001, account.billed_cards_count
end
test "comped accounts are never limited" do
account = accounts(:initech)
account.update_column(:cards_count, 1_000_000)
assert account.exceeding_card_limit?
assert account.nearing_plan_cards_limit?
account.comp
assert_not account.exceeding_card_limit?
assert_not account.nearing_plan_cards_limit?
end
test "uncomping an account restores limits" do
account = accounts(:initech)
account.update_column(:cards_count, 1_000_000)
account.comp
assert_not account.exceeding_card_limit?
account.uncomp
assert account.exceeding_card_limit?
end
end
@@ -0,0 +1,18 @@
require "test_helper"
class Account::SubscriptionTest < ActiveSupport::TestCase
test "get the account plan" do
subscription = Account::Subscription.new(plan_key: "free_v1")
assert_equal Plan[:free_v1], subscription.plan
end
test "check if account is active" do
subscription = Account::Subscription.new(status: "active")
assert subscription.active?
end
test "check if account is paid" do
assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid?
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
end
end
+11
View File
@@ -0,0 +1,11 @@
require "test_helper"
class PlanTest < ActiveSupport::TestCase
test "free plan is free" do
assert Plan[:free_v1].free?
end
test "monthly plan is not free" do
assert_not Plan[:monthly_v1].free?
end
end