Merge pull request #2175 from basecamp/billing+storage

Storage limits + new extra-storage plan
This commit is contained in:
Jorge Manrubia
2025-12-19 11:06:16 +01:00
committed by GitHub
51 changed files with 557 additions and 81 deletions
+2 -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 Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
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_MONTHLY_EXTRA_STORAGE_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)
@@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY
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_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+2 -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 Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
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_MONTHLY_EXTRA_STORAGE_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)
@@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY
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_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS)
STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS)
STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS)
+2 -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 Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
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_MONTHLY_EXTRA_STORAGE_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)
@@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY
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_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_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
@@ -31,6 +31,10 @@
}
}
.settings-subscription__link {
color: var(--settings-subscription-text-color);
}
.settings-subscription__notch {
animation: wiggle 500ms ease;
background: var(--settings-subscription-background);
@@ -39,7 +43,6 @@
color: var(--settings-subscription-text-color);
margin-inline: auto;
padding: 0 0.3em 0 1.2em;
padding: 0.5ch 0.5ch 0.5ch 2ch;
transform: rotate(-1deg);
@media (max-width: 640px) {
@@ -0,0 +1,12 @@
class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController
before_action :ensure_downgradeable
private
def target_plan
Plan.paid
end
def ensure_downgradeable
head :bad_request unless subscription.plan == Plan.paid_with_extra_storage
end
end
@@ -0,0 +1,32 @@
class Account::Subscriptions::UpdatePlanController < ApplicationController
before_action :ensure_admin
def create
portal_session = Stripe::BillingPortal::Session.create(
customer: subscription.stripe_customer_id,
return_url: account_settings_url(anchor: "subscription"),
flow_data: {
type: "subscription_update_confirm",
subscription_update_confirm: {
subscription: subscription.stripe_subscription_id,
items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ]
}
}
)
redirect_to portal_session.url, allow_other_host: true
end
private
def target_plan
raise NotImplementedError
end
def subscription
@subscription ||= Current.account.subscription
end
def stripe_subscription_item_id
Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id
end
end
@@ -0,0 +1,12 @@
class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController
before_action :ensure_upgradeable
private
def target_plan
Plan.paid_with_extra_storage
end
def ensure_upgradeable
head :bad_request unless subscription.plan == Plan.paid
end
end
@@ -9,10 +9,10 @@ class Account::SubscriptionsController < ApplicationController
session = Stripe::Checkout::Session.create \
customer: find_or_create_stripe_customer,
mode: "subscription",
line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ],
line_items: [ { price: plan_param.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 },
metadata: { account_id: Current.account.id, plan_key: plan_param.key },
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: "required",
@@ -22,6 +22,10 @@ class Account::SubscriptionsController < ApplicationController
end
private
def plan_param
@plan_param ||= Plan[params[:plan_key]] || Plan.paid
end
def set_stripe_session
@stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id]
end
@@ -36,7 +40,7 @@ class Account::SubscriptionsController < ApplicationController
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")
Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete")
end
end
end
@@ -12,7 +12,7 @@ class Admin::AccountsController < AdminController
end
def update
@account.override_limits(card_count: overridden_card_count_param)
@account.override_limits(**overridden_limits_params.to_h.symbolize_keys)
redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated"
end
@@ -21,7 +21,7 @@ class Admin::AccountsController < AdminController
@account = Account.find_by!(external_account_id: params[:id])
end
def overridden_card_count_param
params[:account][:overridden_card_count].to_i
def overridden_limits_params
params.expect(account: [ :card_count, :bytes_used ])
end
end
@@ -0,0 +1,8 @@
module Card::Limited
extend ActiveSupport::Concern
private
def ensure_under_limits
head :forbidden if Current.account.exceeding_limits?
end
end
@@ -2,13 +2,10 @@ module Card::LimitedCreation
extend ActiveSupport::Concern
included do
include Card::Limited
# 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? }
before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? }
end
private
def ensure_can_create_cards
head :forbidden if Current.account.exceeding_card_limit?
end
end
@@ -2,11 +2,8 @@ module Card::LimitedPublishing
extend ActiveSupport::Concern
included do
before_action :ensure_can_publish_cards, only: %i[ create ]
end
include Card::Limited
private
def ensure_can_publish_cards
head :forbidden if Current.account.exceeding_card_limit?
end
before_action :ensure_under_limits, only: %i[ create ]
end
end
@@ -49,7 +49,8 @@ class Stripe::WebhooksController < ApplicationController
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)
next_amount_due_in_cents: next_amount_due_for(stripe_subscription),
plan_key: plan_key_for(stripe_subscription)
}
yield subscription_properties if block_given?
@@ -76,4 +77,9 @@ class Stripe::WebhooksController < ApplicationController
rescue Stripe::InvalidRequestError
nil
end
def plan_key_for(stripe_subscription)
price_id = stripe_subscription.items.data.first&.price&.id
Plan.find_by_price_id(price_id)&.key
end
end
+3 -3
View File
@@ -1,6 +1,6 @@
module SubscriptionsHelper
def plan_storage_limit(plan)
number_to_human_size(plan.storage_limit).delete(" ")
def storage_to_human_size(bytes)
number_to_human_size(bytes).delete(" ")
end
def subscription_period_end_action(subscription)
@@ -9,7 +9,7 @@ module SubscriptionsHelper
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
"Your next payment is <b>#{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) }</b> on".html_safe
end
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class Account::BillingWaiver < SaasRecord
belongs_to :account
def subscription
@subscription ||= Account::Subscription.new(plan_key: Plan.paid.key)
@subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage)
end
end
+23 -2
View File
@@ -6,15 +6,20 @@ module Account::Limited
end
NEAR_CARD_LIMIT_THRESHOLD = 100
NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes
def override_limits(card_count:)
(overridden_limits || build_overridden_limits).update!(card_count:)
def override_limits(card_count: nil, bytes_used: nil)
(overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:)
end
def billed_cards_count
overridden_limits&.card_count || cards_count
end
def billed_bytes_used
overridden_limits&.bytes_used || bytes_used
end
def nearing_plan_cards_limit?
plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD
end
@@ -23,6 +28,18 @@ module Account::Limited
plan.limit_cards? && billed_cards_count > plan.card_limit
end
def nearing_plan_storage_limit?
remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD
end
def exceeding_storage_limit?
billed_bytes_used > plan.storage_limit
end
def exceeding_limits?
exceeding_card_limit? || exceeding_storage_limit?
end
def reset_overridden_limits
overridden_limits&.destroy
reload_overridden_limits
@@ -32,4 +49,8 @@ module Account::Limited
def remaining_cards_count
plan.card_limit - billed_cards_count
end
def remaining_storage
plan.storage_limit - billed_bytes_used
end
end
+4
View File
@@ -11,6 +11,10 @@ class Account::Subscription < SaasRecord
@plan ||= Plan.find(plan_key)
end
def plan=(plan)
self.plan_key = plan.key
end
def to_be_canceled?
active? && cancel_at.present?
end
+11 -2
View File
@@ -1,7 +1,8 @@
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"] }
monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] },
monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] }
}
attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id
@@ -19,11 +20,19 @@ class Plan
@paid ||= find(:monthly_v1)
end
def paid_with_extra_storage
@paid_with_extra_storage ||= find(:monthly_extra_storage_v1)
end
def find(key)
@all_by_key ||= all.index_by(&:key).with_indifferent_access
@all_by_key[key]
end
def find_by_price_id(price_id)
all.find { |plan| plan.stripe_price_id == price_id }
end
alias [] find
end
@@ -45,6 +54,6 @@ class Plan
end
def limit_cards?
card_limit != Float::INFINITY
!card_limit.infinite?
end
end
@@ -0,0 +1,18 @@
<h3 class="margin-block-start txt-large font-weight-black txt-tight-lines">
<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards and all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
<% elsif Current.account.exceeding_card_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
<% elsif Current.account.exceeding_storage_limit? %>
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> free storage
<% else %>
Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
<% end %>
</h3>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month</strong> for <strong>unlimited cards</strong>, <strong>unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/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 <%= number_with_delimiter(Plan.free.card_limit) %> cards, unlimited users, and <%= storage_to_human_size(Plan.free.storage_limit) %> of storage.</p>
@@ -0,0 +1,19 @@
<h3 class="margin-block-start txt-x-large font-weight-black txt-tight-lines">
<% if Current.account.exceeding_storage_limit? %>
Youve run out of storage
<% elsif Current.account.nearing_plan_storage_limit? %>
Youve used <strong><%= storage_to_human_size(Current.account.billed_bytes_used) %></strong> of storage
<% else %>
Thank you for buying Fizzy
<% end %>
</h3>
<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>
<p class="margin-block-start-half">
The <strong><%= Current.account.plan.name %></strong> plan includes <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong>. Upgrade to get <strong><%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %></strong> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more.
</p>
<% end %>
<% if Current.account.subscription %>
<%= render "account/settings/subscription", subscription: Current.account.subscription %>
<% end %>
@@ -1,7 +1,20 @@
<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>
<p class="margin-block-start-half"><%= 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 } %>
<p>
<%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>
</p>
<div class="settings-subscription__footer txt-small margin-block">
Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= storage_to_human_size(Current.account.plan.storage_limit) %> of storage.
<% if subscription.plan == Plan.paid %>
<%= button_to "Upgrade", account_subscription_upgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more.
<% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %>
<%= button_to "Downgrade", account_subscription_downgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid.storage_limit) %> storage.
<% end %>
</div>
@@ -3,26 +3,9 @@
<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>
<%= render "account/settings/free_plan" %>
<% 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 %>
<%= render "account/settings/paid_plan" %>
<% end %>
</section>
<% end %>
@@ -1,12 +1,9 @@
<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 %>
<%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
<% 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>
<%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
<% end %>
</div>
@@ -0,0 +1,4 @@
<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 %>
@@ -0,0 +1,4 @@
<strong>Youve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to get more</span>
<% end %>
@@ -0,0 +1,3 @@
<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>
@@ -0,0 +1,3 @@
<div class="pad-inline pad-block-half">
<strong>This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.</strong>
</div>
+12 -5
View File
@@ -11,14 +11,21 @@
<dt class="font-weight-bold">Actual card count:</dt>
<dd class="margin-inline-start-none"><%= @account.cards_count %></dd>
</div>
<div class="flex gap-half">
<dt class="font-weight-bold">Actual bytes used:</dt>
<dd class="margin-inline-start-none"><%= storage_to_human_size(@account.bytes_used) %></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 class="flex align-center gap-half">
<%= form.label :card_count, "Override card count", class: "font-weight-bold" %>
<%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %>
</div>
<div class="flex align-center gap-half">
<%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %>
<%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
</div>
<button type="submit" class="btn btn--reversed">Save</button>
@@ -1,4 +1,4 @@
<% if Current.account.exceeding_card_limit? %>
<% if Current.account.exceeding_limits? %>
<%= render "account/subscriptions/upgrade" %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
@@ -1,9 +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 %>
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
<% if Current.user.admin? %>
<%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
<%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
<% else %>
<%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
<%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
<% end %>
</div>
@@ -0,0 +1 @@
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>.
@@ -0,0 +1 @@
Youve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <strong><%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %></strong>.
@@ -0,0 +1 @@
This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon.
@@ -0,0 +1 @@
This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon.
+1
View File
@@ -45,6 +45,7 @@ env:
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
+1
View File
@@ -51,6 +51,7 @@ env:
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
+1
View File
@@ -51,6 +51,7 @@ env:
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
- STRIPE_MONTHLY_V1_PRICE_ID
- STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
@@ -0,0 +1,5 @@
class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2]
def change
add_column :account_overridden_limits, :bytes_used, :bigint
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do
ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) 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
@@ -20,6 +20,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do
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.bigint "bytes_used"
t.integer "card_count"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+4 -1
View File
@@ -30,13 +30,15 @@ secrets_escaped = `kamal secrets fetch \
--account basecamp \
--from "Deploy/Fizzy" \
"Development/STRIPE_SECRET_KEY" \
"Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null`
"Development/STRIPE_MONTHLY_V1_PRICE_ID" \
"Development/STRIPE_MONTHLY_EXTRA_STORAGE_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"]
stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"]
# Clear previous log file
File.write(LOG_FILE, "")
@@ -72,6 +74,7 @@ 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_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_price_id}")
puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret
# Informational message to stderr (won't be eval'd)
+7 -2
View File
@@ -10,7 +10,7 @@ module Fizzy
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"
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
end
initializer "fizzy_saas.assets" do |app|
@@ -22,7 +22,12 @@ module Fizzy
app.routes.prepend do
namespace :account do
resource :billing_portal, only: :show
resource :subscription
resource :subscription do
scope module: :subscriptions do
resource :upgrade, only: :create
resource :downgrade, only: :create
end
end
end
namespace :stripe do
@@ -0,0 +1,79 @@
require "test_helper"
class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest
# Nearing limits - shown in card creation footer
test "admin sees nearing card limit notice" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 950)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /upgrade to unlimited/i, response.body
end
test "admin sees nearing storage limit notice" do
sign_in_as :mike
Account.any_instance.stubs(:bytes_used).returns(600.megabytes)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /upgrade to get more/i, response.body
end
# Exceeding limits - shown instead of create buttons
test "admin sees exceeding card limit notice" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 1001)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /youve used your.*free cards/i, response.body
end
test "admin sees exceeding storage limit notice" do
sign_in_as :mike
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /youve run out of.*free storage/i, response.body
end
# Paid accounts under limits - no notices
test "paid account under limits sees no notices" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get card_path(cards(:layout), script_name: accounts(:"37s").slug)
assert_response :success
assert_no_match /upgrade/i, response.body
assert_no_match /youve used your/i, response.body
end
# Comped accounts under limits - no notices
test "comped account under limits sees no notices" do
sign_in_as :mike
accounts(:initech).comp
get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_no_match /upgrade/i, response.body
assert_no_match /youve used your/i, response.body
end
end
@@ -0,0 +1,35 @@
require "test_helper"
require "ostruct"
class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage)
end
test "downgrade redirects to stripe billing portal" do
stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
post account_subscription_downgrade_path
assert_redirected_to "https://billing.stripe.com/session/abc123"
end
test "downgrade requires admin" do
logout_and_sign_in_as :david
post account_subscription_downgrade_path
assert_response :forbidden
end
test "downgrade requires downgradeable plan" do
accounts(:"37s").subscription.update!(plan: Plan.paid)
post account_subscription_downgrade_path
assert_response :bad_request
end
end
@@ -0,0 +1,62 @@
require "test_helper"
class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest
test "free users see current usage" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 3)
get account_settings_path(script_name: accounts(:initech).slug)
assert_response :success
assert_match /Youve used.*3.*free cards out of 1,000/i, response.body
end
test "paid users see thank you message" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "h3", text: "Thank you for buying Fizzy"
end
test "regular plan users see upgrade option" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "button", text: /upgrade/i
assert_select "button", text: /downgrade/i, count: 0
end
test "extra storage plan users see downgrade option" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "button", text: /downgrade/i
end
test "comped accounts see no subscription panel" do
sign_in_as :mike
accounts(:initech).comp
get account_settings_path(script_name: accounts(:initech).slug)
assert_response :success
assert_no_match /thank you for buying/i, response.body
assert_no_match /free cards out of/i, response.body
assert_no_match /upgrade/i, response.body
assert_no_match /downgrade/i, response.body
end
end
@@ -0,0 +1,35 @@
require "test_helper"
require "ostruct"
class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid)
end
test "upgrade redirects to stripe billing portal" do
stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
post account_subscription_upgrade_path
assert_redirected_to "https://billing.stripe.com/session/abc123"
end
test "upgrade requires admin" do
logout_and_sign_in_as :david
post account_subscription_upgrade_path
assert_response :forbidden
end
test "upgrade requires upgradeable plan" do
accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage)
post account_subscription_upgrade_path
assert_response :bad_request
end
end
@@ -43,4 +43,18 @@ class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest
post account_subscription_path
assert_response :forbidden
end
test "create with custom plan_key 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).with do |params|
params[:metadata][:plan_key] == :monthly_extra_storage_v1
end.returns(session)
post account_subscription_path(plan_key: :monthly_extra_storage_v1)
assert_redirected_to "https://checkout.stripe.com/session123"
end
end
@@ -34,7 +34,7 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
sign_in_as :david
untenanted do
patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } }
patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { card_count: 500 } }
assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
end
@@ -40,4 +40,16 @@ class Card::LimitedCreationTest < ActionDispatch::IntegrationTest
assert_response :redirect
assert Card.last.drafted?
end
test "cannot create cards via JSON when storage limit exceeded" do
sign_in_as :mike
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
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
end
@@ -11,4 +11,15 @@ class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest
assert_response :forbidden
assert cards(:unfinished_thoughts).reload.drafted?
end
test "cannot publish cards when storage limit exceeded" do
sign_in_as :mike
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :forbidden
assert cards(:unfinished_thoughts).reload.drafted?
end
end
+41
View File
@@ -73,4 +73,45 @@ class Account::LimitedTest < ActiveSupport::TestCase
assert account.exceeding_card_limit?
end
test "detect nearing storage limit" do
# Paid plans have large storage limits
accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes)
assert_not accounts(:"37s").nearing_plan_storage_limit?
# Free plan not near limit
accounts(:initech).stubs(:bytes_used).returns(400.megabytes)
assert_not accounts(:initech).nearing_plan_storage_limit?
# Free plan near limit
accounts(:initech).stubs(:bytes_used).returns(600.megabytes)
assert accounts(:initech).nearing_plan_storage_limit?
end
test "detect exceeding storage limit" do
# Free plan under limit
accounts(:initech).stubs(:bytes_used).returns(900.megabytes)
assert_not accounts(:initech).exceeding_storage_limit?
# Free plan over limit
accounts(:initech).stubs(:bytes_used).returns(1.1.gigabytes)
assert accounts(:initech).exceeding_storage_limit?
end
test "override bytes_used limits" do
account = accounts(:initech)
account.stubs(:bytes_used).returns(1.1.gigabytes)
assert account.exceeding_storage_limit?
assert_equal 1.1.gigabytes, account.billed_bytes_used
account.override_limits bytes_used: 500.megabytes
assert_not account.exceeding_storage_limit?
assert_equal 500.megabytes, account.billed_bytes_used
assert_equal 1.1.gigabytes, account.bytes_used # original unchanged
account.reset_overridden_limits
assert account.exceeding_storage_limit?
assert_equal 1.1.gigabytes, account.billed_bytes_used
end
end
+7
View File
@@ -8,4 +8,11 @@ class PlanTest < ActiveSupport::TestCase
test "monthly plan is not free" do
assert_not Plan[:monthly_v1].free?
end
test "find plan by its price id" do
Plan.paid.stubs(:stripe_price_id).returns("price_monthly_v1")
assert_equal Plan.paid, Plan.find_by_price_id("price_monthly_v1")
assert_nil Plan.find_by_price_id("unknown_price_id")
end
end