diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 0fb3db6609..658b75c59f 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -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) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 672359a132..768abbcd04 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -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) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 7121f5e3ac..e98540135f 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -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) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 539f79b32c..326b95b40e 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -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) { diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb new file mode 100644 index 0000000000..d067958419 --- /dev/null +++ b/saas/app/controllers/account/subscriptions/downgrades_controller.rb @@ -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 diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb new file mode 100644 index 0000000000..c6a665cecb --- /dev/null +++ b/saas/app/controllers/account/subscriptions/update_plan_controller.rb @@ -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 diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb new file mode 100644 index 0000000000..1de6d8c6cf --- /dev/null +++ b/saas/app/controllers/account/subscriptions/upgrades_controller.rb @@ -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 diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 8ddfb4b7a3..bbc4ec47ab 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -9,10 +9,10 @@ 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 } ], + 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 @@ def create 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 @@ def find_stripe_customer 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 diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb index ba6200d1b8..26a2b9c822 100644 --- a/saas/app/controllers/admin/accounts_controller.rb +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -12,7 +12,7 @@ def edit 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 @@ 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 + def overridden_limits_params + params.expect(account: [ :card_count, :bytes_used ]) end end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 0000000000..0576c6c4e2 --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,8 @@ +module Card::Limited + extend ActiveSupport::Concern + + private + def ensure_under_limits + head :forbidden if Current.account.exceeding_limits? + end +end diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb index b4c7734491..0e992008a2 100644 --- a/saas/app/controllers/concerns/card/limited_creation.rb +++ b/saas/app/controllers/concerns/card/limited_creation.rb @@ -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 diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb index b92ab06f30..8b60a4b530 100644 --- a/saas/app/controllers/concerns/card/limited_publishing.rb +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -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 diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index bf00fc23fc..cdfb6bcec6 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -49,7 +49,8 @@ def sync_subscription(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) + 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 @@ def next_amount_due_for(stripe_subscription) 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 diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index a1c4c07d95..48ce0372fa 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -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 @@ def subscription_period_end_action(subscription) elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe + "Your next payment is #{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) } on".html_safe end end end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb index c1968522a3..a9ec35eb82 100644 --- a/saas/app/models/account/billing_waiver.rb +++ b/saas/app/models/account/billing_waiver.rb @@ -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 diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index f7eb3cbc14..8e846a1172 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -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 @@ def exceeding_card_limit? 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 @@ def reset_overridden_limits def remaining_cards_count plan.card_limit - billed_cards_count end + + def remaining_storage + plan.storage_limit - billed_bytes_used + end end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 0eac71b024..dd7833dfca 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -11,6 +11,10 @@ def plan @plan ||= Plan.find(plan_key) end + def plan=(plan) + self.plan_key = plan.key + end + def to_be_canceled? active? && cancel_at.present? end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 7ef7f922e5..e77ea81e8e 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -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 @@ def paid @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 @@ def paid? end def limit_cards? - card_limit != Float::INFINITY + !card_limit.infinite? end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb new file mode 100644 index 0000000000..1f59ff49f1 --- /dev/null +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -0,0 +1,18 @@ +

+ <% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage + <% elsif Current.account.exceeding_card_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards + <% elsif Current.account.exceeding_storage_limit? %> + You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage + <% else %> + You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %> + <% end %> +

+ +

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+ +<%= 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 } } %> + +

Cancel anytime, no contracts, take your data with you whenever.

+ diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb new file mode 100644 index 0000000000..426568eae4 --- /dev/null +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -0,0 +1,19 @@ +

+ <% if Current.account.exceeding_storage_limit? %> + You’ve run out of storage + <% elsif Current.account.nearing_plan_storage_limit? %> + You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> of storage + <% else %> + Thank you for buying Fizzy + <% end %> +

+ +<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %> +

+ The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %>. Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more. +

+<% end %> + +<% if Current.account.subscription %> + <%= render "account/settings/subscription", subscription: Current.account.subscription %> +<% end %> diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 3aef9b8537..c3e6296072 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -1,7 +1,20 @@ - - <% if subscription.current_period_end %> -

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

+

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

<% end %> -<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> +

+ <%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %> +

+ + + diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 44949e0223..5a5f0eace8 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -3,26 +3,9 @@

Subscription

<% if Current.account.plan.free? %> - <% if Current.account.exceeding_card_limit? %> -

You’ve used up your <%= Plan.free.card_limit %> free cards

- <% else %> -

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

- <% end %> - -

If you’d like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it’s only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= plan_storage_limit(Plan.paid) %> of storage.

- - <%= 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 } } %> - -

Cancel anytime, no contracts, take your data with you whenever.

- + <%= render "account/settings/free_plan" %> <% else %> -

Thank you for buying Fizzy

- - <% if Current.account.subscription %> - <%= render "account/settings/subscription", subscription: Current.account.subscription %> - - <% end %> + <%= render "account/settings/paid_plan" %> <% end %> - <% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index d1c06e7f01..449fecf16d 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,12 +1,9 @@
<% if Current.user.admin? %> - You’ve used your <%= Plan.free.card_limit %> free cards. - <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade to Unlimited - <% 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 %> -
- This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. -
+ <%= 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 %>
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb new file mode 100644 index 0000000000..3dc759d9e1 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb @@ -0,0 +1,4 @@ +You’ve used your <%= Plan.free.card_limit %> free cards. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade to Unlimited +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb new file mode 100644 index 0000000000..8770c14608 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb @@ -0,0 +1,4 @@ +You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade to get more +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb new file mode 100644 index 0000000000..f3f65f3be5 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb @@ -0,0 +1,3 @@ +
+ This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb new file mode 100644 index 0000000000..f230443a12 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb @@ -0,0 +1,3 @@ +
+ This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more. +
diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 49c99773d8..950ecc761a 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -11,14 +11,21 @@
Actual card count:
<%= @account.cards_count %>
+
+
Actual bytes used:
+
<%= storage_to_human_size(@account.bytes_used) %>
+
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %> -
- <%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %> -
- <%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %> -
+
+ <%= form.label :card_count, "Override card count", class: "font-weight-bold" %> + <%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %> +
+ +
+ <%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %> + <%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb index 295c9bd19d..8b0edd308f 100644 --- a/saas/app/views/cards/container/footer/saas/_create.html.erb +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -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 %> diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb index e40aa9fb51..4cf480f3c1 100644 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -1,9 +1,9 @@ -<% if Current.account.nearing_plan_cards_limit? %> -
- <% if Current.user.admin? %> - You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. - <% else %> - This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon - <% end %> -
-<% end %> +
+ <% 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 %> +
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb new file mode 100644 index 0000000000..caa7bd9584 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb @@ -0,0 +1 @@ +You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb new file mode 100644 index 0000000000..ab1a3ca34c --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +You’ve 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" %>. <%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb new file mode 100644 index 0000000000..8ab80e1629 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb new file mode 100644 index 0000000000..25428980a3 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb @@ -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. diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 4aefe56638..35607d58e7 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -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: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index 70556948e9..443bc81e86 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -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: diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index 2c0ae9516e..bfcc5f4414 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -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: diff --git a/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb new file mode 100644 index 0000000000..85adad44ac --- /dev/null +++ b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb @@ -0,0 +1,5 @@ +class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2] + def change + add_column :account_overridden_limits, :bytes_used, :bigint + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 59c78f4cba..7e9b80b92d 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -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 @@ 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 diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index 2033385ffb..5f4820cc7e 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -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) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 532f0a3845..757de6bebc 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,7 +10,7 @@ class Engine < ::Rails::Engine 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 @@ class Engine < ::Rails::Engine 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 diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb new file mode 100644 index 0000000000..3f24c82c53 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb @@ -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 /you’ve 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 /you’ve 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 /you’ve 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 /you’ve used your/i, response.body + end +end diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb new file mode 100644 index 0000000000..e4680a1f83 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -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 diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb new file mode 100644 index 0000000000..63feac2c5d --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/settings_test.rb @@ -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 /You’ve 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 diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb new file mode 100644 index 0000000000..65e268af15 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -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 diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb index d98ac1d912..9e086df39d 100644 --- a/saas/test/controllers/accounts/subscriptions_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -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 diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb index 0b830919af..3849c07a56 100644 --- a/saas/test/controllers/admin/accounts_controller_test.rb +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -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 diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb index b3477735c9..4105091bea 100644 --- a/saas/test/controllers/card/limited_creation_test.rb +++ b/saas/test/controllers/card/limited_creation_test.rb @@ -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 diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb index 2eb57471ea..90e4e56963 100644 --- a/saas/test/controllers/card/limited_publishing_test.rb +++ b/saas/test/controllers/card/limited_publishing_test.rb @@ -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 diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index 335d6a37c7..828386023e 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -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 diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb index 6e0d70b5cc..54ed279d64 100644 --- a/saas/test/models/plan_test.rb +++ b/saas/test/models/plan_test.rb @@ -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