Add Stripe-based billing system
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Jason Zimdars <jz@37signals.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,38 @@
|
||||
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 }
|
||||
|
||||
redirect_to session.url, allow_other_host: true
|
||||
end
|
||||
|
||||
private
|
||||
def set_stripe_session
|
||||
@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,25 @@
|
||||
class Admin::AccountsController < AdminController
|
||||
layout "public"
|
||||
|
||||
before_action :set_account, only: %i[ edit update ]
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update!(account_params)
|
||||
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated"
|
||||
end
|
||||
|
||||
private
|
||||
def set_account
|
||||
@account = Account.find_by!(external_account_id: params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.expect(account: [ :cards_count ])
|
||||
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 Card::Limited
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :ensure_can_create_cards, only: %i[ create ]
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_can_create_cards
|
||||
head :forbidden if Current.account.exceeding_card_limit?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
class Stripe::WebhooksController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :require_account
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
def create
|
||||
event = verify_webhook_signature
|
||||
return head :bad_request unless event
|
||||
|
||||
dispatch_stripe_event(event)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
def dispatch_stripe_event(event)
|
||||
case event.type
|
||||
when "checkout.session.completed"
|
||||
handle_checkout_completed(event.data.object)
|
||||
when "customer.subscription.updated"
|
||||
handle_subscription_updated(event.data.object)
|
||||
when "customer.subscription.deleted"
|
||||
handle_subscription_deleted(event.data.object)
|
||||
when "invoice.payment_failed"
|
||||
handle_payment_failed(event.data.object)
|
||||
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 handle_checkout_completed(session)
|
||||
return unless session.mode == "subscription"
|
||||
|
||||
subscription = find_subscription_by_customer(session.customer)
|
||||
return unless subscription
|
||||
|
||||
stripe_subscription = Stripe::Subscription.retrieve(session.subscription)
|
||||
|
||||
subscription.update! \
|
||||
stripe_subscription_id: stripe_subscription.id,
|
||||
plan_key: session.metadata["plan_key"],
|
||||
status: stripe_subscription.status,
|
||||
current_period_end: extract_current_period_end(stripe_subscription)
|
||||
end
|
||||
|
||||
def handle_subscription_updated(stripe_subscription)
|
||||
if subscription = find_subscription_by_customer(stripe_subscription.customer)
|
||||
subscription.update! \
|
||||
status: stripe_subscription.status,
|
||||
current_period_end: extract_current_period_end(stripe_subscription),
|
||||
cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil
|
||||
end
|
||||
end
|
||||
|
||||
def handle_subscription_deleted(stripe_subscription)
|
||||
if subscription = find_subscription_by_customer(stripe_subscription.customer)
|
||||
subscription.update!(status: "canceled", stripe_subscription_id: nil)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_payment_failed(invoice)
|
||||
if subscription = find_subscription_by_customer(invoice.customer)
|
||||
subscription.update!(status: "past_due")
|
||||
end
|
||||
end
|
||||
|
||||
def find_subscription_by_customer(customer_id)
|
||||
Account::Subscription.find_by(stripe_customer_id: customer_id)
|
||||
end
|
||||
|
||||
def extract_current_period_end(stripe_subscription)
|
||||
timestamp = stripe_subscription.items.data.first&.current_period_end
|
||||
Time.at(timestamp) if timestamp
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
module SubscriptionsHelper
|
||||
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>$#{ subscription.plan.price }</b> will be billed on".html_safe
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module Account::Billing
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :subscription, class_name: "Account::Subscription", dependent: :destroy
|
||||
end
|
||||
|
||||
NEAR_CARD_LIMIT_THRESHOLD = 100
|
||||
|
||||
def plan
|
||||
active_subscription&.plan || Plan.free
|
||||
end
|
||||
|
||||
def active_subscription
|
||||
subscription if subscription&.active?
|
||||
end
|
||||
|
||||
def subscribed?
|
||||
subscription.present?
|
||||
end
|
||||
|
||||
def nearing_plan_cards_limit?
|
||||
plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD
|
||||
end
|
||||
|
||||
def exceeding_card_limit?
|
||||
cards_count > plan.card_limit
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
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.find(plan_key)
|
||||
end
|
||||
|
||||
def to_be_canceled?
|
||||
active? && cancel_at.present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class Plan
|
||||
PLANS = {
|
||||
free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes },
|
||||
monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID", "price_1SaHykRwChFE4it8PePOdDpS") }
|
||||
}
|
||||
|
||||
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 ||= all.find(&:free?)
|
||||
end
|
||||
|
||||
def paid
|
||||
@paid ||= all.find(&:paid?)
|
||||
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
|
||||
|
||||
def formatted_storage_limit
|
||||
ActionController::Base.helpers.number_to_human_size(storage_limit).delete(" ")
|
||||
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? %>
|
||||
<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">You've 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">You've used <u><%= Current.account.cards_count %></u> free cards out of <%= Plan.free.card_limit %></h3>
|
||||
<% end %>
|
||||
|
||||
<p class="margin-none-block-start">If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only <strong>$<%= Plan.paid.price %>/month</strong> for <strong>unlimited cards</strong> + <strong>unlimited users</strong>. You'll also get <strong><%= number_to_human_size(Plan.paid.formatted_storage_limit) %></strong> of storage. </p>
|
||||
|
||||
<%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
|
||||
|
||||
<p>Cancel any time, no contracts, take your data with you whenever.</p>
|
||||
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now you're on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= Plan.free.card_limit %> cards, unlimitted users, and <%= number_to_human_size(Plan.free.formatted_storage_limit) %> 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 you're on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimitted cards, unlimitted users, and <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
</section>
|
||||
<% end %>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="settings-subscription__notch card-perma__notch card-perma__notch--bottom">
|
||||
<strong>You've 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 Unlimitted</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% @page_title = "Thank you" %>
|
||||
|
||||
<% if @session&.payment_status == "paid" %>
|
||||
<section class="panel 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. You're 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,14 @@
|
||||
<h1>Edit Account <%= @account.external_account_id %></h1>
|
||||
|
||||
<p><strong>Name:</strong> <%= @account.name %></p>
|
||||
|
||||
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %>
|
||||
<p>
|
||||
<%= form.label :cards_count %>
|
||||
<%= form.number_field :cards_count %>
|
||||
</p>
|
||||
|
||||
<%= form.submit "Update" %>
|
||||
<% end %>
|
||||
|
||||
<p><%= link_to "Back", saas.admin_accounts_path %></p>
|
||||
@@ -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 %>
|
||||
@@ -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,5 @@
|
||||
<% if Current.account.nearing_plan_cards_limit? %>
|
||||
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
|
||||
You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong><%= link_to "Upgrade to unlimitted", account_settings_path(anchor: "subscription") %></strong>.
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,5 +3,8 @@ 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
|
||||
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
|
||||
+16
-1
@@ -10,7 +10,22 @@
|
||||
#
|
||||
# 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_03_144630) do
|
||||
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.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
|
||||
|
||||
@@ -18,7 +18,7 @@ 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}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"]
|
||||
end
|
||||
|
||||
spec.add_dependency "rails", ">= 8.1.0.beta1"
|
||||
|
||||
@@ -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,6 +127,8 @@ module Fizzy
|
||||
end
|
||||
|
||||
config.to_prepare do
|
||||
::Account.include(Account::Billing)
|
||||
::CardsController.include(Card::Limited)
|
||||
::Signup.prepend(Fizzy::Saas::Signup)
|
||||
|
||||
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,47 @@
|
||||
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,58 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
def saas
|
||||
Fizzy::Saas::Engine.routes.url_helpers
|
||||
end
|
||||
|
||||
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 update cards_count" do
|
||||
sign_in_as :david
|
||||
|
||||
untenanted do
|
||||
patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 500 } }
|
||||
assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
|
||||
end
|
||||
|
||||
assert_equal 500, accounts(:"37s").reload.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,27 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::StatsControllerTest < ActionDispatch::IntegrationTest
|
||||
def saas
|
||||
Fizzy::Saas::Engine.routes.url_helpers
|
||||
end
|
||||
|
||||
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,15 @@
|
||||
require "test_helper"
|
||||
|
||||
class Card::LimitedTest < ActionDispatch::IntegrationTest
|
||||
test "cannot create cards 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)
|
||||
end
|
||||
|
||||
assert_response :forbidden
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,95 @@
|
||||
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", status: "active", 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)
|
||||
|
||||
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" do
|
||||
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
|
||||
|
||||
event = stripe_event("customer.subscription.updated",
|
||||
customer: "cus_test123",
|
||||
status: "past_due",
|
||||
cancel_at: nil,
|
||||
items: stub_items(1.month.from_now.to_i)
|
||||
)
|
||||
|
||||
Stripe::Webhook.stubs(:construct_event).returns(event)
|
||||
|
||||
post stripe_webhooks_path
|
||||
|
||||
assert_response :ok
|
||||
assert_equal "past_due", @subscription.reload.status
|
||||
end
|
||||
|
||||
test "subscription deleted cancels subscription" do
|
||||
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
|
||||
|
||||
event = stripe_event("customer.subscription.deleted", customer: "cus_test123")
|
||||
|
||||
Stripe::Webhook.stubs(:construct_event).returns(event)
|
||||
|
||||
post stripe_webhooks_path
|
||||
|
||||
assert_response :ok
|
||||
@subscription.reload
|
||||
assert_equal "canceled", @subscription.status
|
||||
assert_nil @subscription.stripe_subscription_id
|
||||
end
|
||||
|
||||
test "payment failed marks subscription as past due" do
|
||||
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
|
||||
|
||||
event = stripe_event("invoice.payment_failed", customer: "cus_test123")
|
||||
|
||||
Stripe::Webhook.stubs(:construct_event).returns(event)
|
||||
|
||||
post stripe_webhooks_path
|
||||
|
||||
assert_response :ok
|
||||
assert_equal "past_due", @subscription.reload.status
|
||||
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
@@ -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 %>
|
||||
@@ -0,0 +1,49 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::BillingTest < ActiveSupport::TestCase
|
||||
test "active subscription" do
|
||||
account = Account.create!(name: "Test")
|
||||
|
||||
# No subscription
|
||||
assert_nil account.active_subscription
|
||||
|
||||
# Subscription but it is not active
|
||||
account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test")
|
||||
assert_nil account.active_subscription
|
||||
|
||||
# Active subscription exists
|
||||
account.subscription.update!(status: "active")
|
||||
assert_equal account.subscription, account.active_subscription
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user