From 984a5dd4ce158b60b3dd8733dd03fa0de99b73ad Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 10 Dec 2025 11:12:30 +0100 Subject: [PATCH 01/36] Add Stripe-based billing system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Jason Zimdars --- saas/.kamal/secrets.beta | 5 +- saas/.kamal/secrets.production | 5 +- saas/.kamal/secrets.staging | 5 +- saas/app/assets/stylesheets/fizzy/saas.css | 64 +++++++++ .../stylesheets/fizzy/saas/application.css | 15 --- .../account/billing_portals_controller.rb | 19 +++ .../account/subscriptions_controller.rb | 38 ++++++ .../admin/account_searches_controller.rb | 5 + .../controllers/admin/accounts_controller.rb | 25 ++++ .../app/controllers/admin/stats_controller.rb | 20 +++ saas/app/controllers/concerns/card/limited.rb | 12 ++ .../controllers/stripe/webhooks_controller.rb | 83 ++++++++++++ saas/app/helpers/subscriptions_helper.rb | 11 ++ saas/app/models/account/billing.rb | 29 ++++ saas/app/models/account/subscription.rb | 17 +++ saas/app/models/plan.rb | 54 ++++++++ .../account/settings/_subscription.html.erb | 7 + .../settings/_subscription_panel.html.erb | 28 ++++ .../account/subscriptions/_upgrade.html.erb | 6 + .../views/account/subscriptions/show.html.erb | 9 ++ saas/app/views/admin/accounts/edit.html.erb | 14 ++ saas/app/views/admin/accounts/index.html.erb | 6 + saas/app/views/admin/stats/show.html.erb | 126 ++++++++++++++++++ .../container/footer/saas/_create.html.erb | 5 + .../footer/saas/_near_notice.html.erb | 5 + saas/config/deploy.beta.yml | 3 + saas/config/deploy.production.yml | 3 + saas/config/deploy.staging.yml | 3 + saas/config/routes.rb | 3 + ...1203144630_create_account_subscriptions.rb | 15 +++ saas/db/saas_schema.rb | 17 ++- saas/fizzy-saas.gemspec | 2 +- saas/lib/fizzy/saas/engine.rb | 28 ++++ saas/lib/fizzy/saas/testing.rb | 11 ++ .../billing_portals_controller_test.rb | 29 ++++ .../accounts/subscriptions_controller_test.rb | 47 +++++++ .../admin/accounts_controller_test.rb | 58 ++++++++ .../admin/stats_controller_test.rb | 27 ++++ saas/test/controllers/card/limited_test.rb | 15 +++ .../stripe/webhooks_controller_test.rb | 95 +++++++++++++ saas/test/fixtures/account_subscriptions.yml | 11 ++ saas/test/models/account/billing_test.rb | 49 +++++++ saas/test/models/account/subscription_test.rb | 18 +++ saas/test/models/plan_test.rb | 11 ++ 44 files changed, 1038 insertions(+), 20 deletions(-) create mode 100644 saas/app/assets/stylesheets/fizzy/saas.css delete mode 100644 saas/app/assets/stylesheets/fizzy/saas/application.css create mode 100644 saas/app/controllers/account/billing_portals_controller.rb create mode 100644 saas/app/controllers/account/subscriptions_controller.rb create mode 100644 saas/app/controllers/admin/account_searches_controller.rb create mode 100644 saas/app/controllers/admin/accounts_controller.rb create mode 100644 saas/app/controllers/admin/stats_controller.rb create mode 100644 saas/app/controllers/concerns/card/limited.rb create mode 100644 saas/app/controllers/stripe/webhooks_controller.rb create mode 100644 saas/app/helpers/subscriptions_helper.rb create mode 100644 saas/app/models/account/billing.rb create mode 100644 saas/app/models/account/subscription.rb create mode 100644 saas/app/models/plan.rb create mode 100644 saas/app/views/account/settings/_subscription.html.erb create mode 100644 saas/app/views/account/settings/_subscription_panel.html.erb create mode 100644 saas/app/views/account/subscriptions/_upgrade.html.erb create mode 100644 saas/app/views/account/subscriptions/show.html.erb create mode 100644 saas/app/views/admin/accounts/edit.html.erb create mode 100644 saas/app/views/admin/accounts/index.html.erb create mode 100644 saas/app/views/admin/stats/show.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_create.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/_near_notice.html.erb create mode 100644 saas/db/migrate/20251203144630_create_account_subscriptions.rb create mode 100644 saas/test/controllers/accounts/billing_portals_controller_test.rb create mode 100644 saas/test/controllers/accounts/subscriptions_controller_test.rb create mode 100644 saas/test/controllers/admin/accounts_controller_test.rb create mode 100644 saas/test/controllers/admin/stats_controller_test.rb create mode 100644 saas/test/controllers/card/limited_test.rb create mode 100644 saas/test/controllers/stripe/webhooks_controller_test.rb create mode 100644 saas/test/fixtures/account_subscriptions.yml create mode 100644 saas/test/models/account/billing_test.rb create mode 100644 saas/test/models/account/subscription_test.rb create mode 100644 saas/test/models/plan_test.rb diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 744e38da3..0fb3db660 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) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) +STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index d94718f6c..672359a13 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) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) +STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 4dad7c099..7121f5e3a 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) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) +STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css new file mode 100644 index 000000000..318c749e0 --- /dev/null +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -0,0 +1,64 @@ +/* Subscriptions +/* ------------------------------------------------------------------------ */ +:root { + --settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter))); + --settings-subscription-color: oklch(var(--lch-violet-medium)); + --settings-subscription-text-color: oklch(var(--lch-violet-dark)); + + .settings-subscription__button { + --btn-background: var(--settings-subscription-color); + --btn-border-color: var(--color-canvas); + --btn-color: var(--color-canvas); + --focus-ring-color: var(--color-ink); + } + + .settings-subscription__divider { + --divider-color: currentColor; + + color: var(--settings-subscription-color); + margin-block-start: calc(var(--block-space-half) * -1); + } + + .settings-subscription__footer { + color: var(--settings-subscription-text-color); + + &::before { + content: "————"; + display: block; + margin: auto; + text-align: center; + } + } + + .settings-subscription__notch { + animation: wiggle 500ms ease; + background: var(--settings-subscription-background); + box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); + border-radius: 3em; + color: var(--settings-subscription-text-color); + margin-inline: auto; + padding: 0 0.3em 0 1.2em; + transform: rotate(-1deg); + + @media (max-width: 640px) { + padding-block: 0.6em; + padding-inline-start: 0.3em; + } + + .btn { + margin-block: 0.3em; + } + } + + .settings-subscription__panel { + background: var(--settings-subscription-background); + } + + .settings_subscription__warning { + color: var(--settings-subscription-text-color); + + a { + color: var(--settings-subscription-text-color); + } + } +} diff --git a/saas/app/assets/stylesheets/fizzy/saas/application.css b/saas/app/assets/stylesheets/fizzy/saas/application.css deleted file mode 100644 index 0ebd7fe82..000000000 --- a/saas/app/assets/stylesheets/fizzy/saas/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css, which will include all the files - * listed below. - * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, - * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. - * - * You're free to add application-wide styles to this file and they'll appear at the bottom of the - * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ diff --git a/saas/app/controllers/account/billing_portals_controller.rb b/saas/app/controllers/account/billing_portals_controller.rb new file mode 100644 index 000000000..67eaffe6c --- /dev/null +++ b/saas/app/controllers/account/billing_portals_controller.rb @@ -0,0 +1,19 @@ +class Account::BillingPortalsController < ApplicationController + before_action :ensure_admin + before_action :ensure_subscribed_account + + def show + redirect_to create_stripe_billing_portal_session.url, allow_other_host: true + end + + private + def ensure_subscribed_account + unless Current.account.subscribed? + redirect_to account_subscription_path, alert: "No billing information found" + end + end + + def create_stripe_billing_portal_session + Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url) + end +end diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb new file mode 100644 index 000000000..5a7f27fcd --- /dev/null +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -0,0 +1,38 @@ +class Account::SubscriptionsController < ApplicationController + before_action :ensure_admin + before_action :set_stripe_session, only: :show + + def show + end + + def create + session = Stripe::Checkout::Session.create \ + customer: find_or_create_stripe_customer, + mode: "subscription", + line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ], + success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url: account_subscription_url, + metadata: { account_id: Current.account.id, plan_key: Plan.paid.key } + + redirect_to session.url, allow_other_host: true + end + + private + def set_stripe_session + @session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] + end + + def find_or_create_stripe_customer + find_stripe_customer || create_stripe_customer + end + + def find_stripe_customer + Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id + end + + def create_stripe_customer + Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer| + Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete") + end + end +end diff --git a/saas/app/controllers/admin/account_searches_controller.rb b/saas/app/controllers/admin/account_searches_controller.rb new file mode 100644 index 000000000..db655a9ef --- /dev/null +++ b/saas/app/controllers/admin/account_searches_controller.rb @@ -0,0 +1,5 @@ +class Admin::AccountSearchesController < AdminController + def create + redirect_to saas.edit_admin_account_path(params[:q]) + end +end diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb new file mode 100644 index 000000000..dda0794e4 --- /dev/null +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -0,0 +1,25 @@ +class Admin::AccountsController < AdminController + layout "public" + + before_action :set_account, only: %i[ edit update ] + + def index + end + + def edit + end + + def update + @account.update!(account_params) + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated" + end + + private + def set_account + @account = Account.find_by!(external_account_id: params[:id]) + end + + def account_params + params.expect(account: [ :cards_count ]) + end +end diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb new file mode 100644 index 000000000..6d508fc2c --- /dev/null +++ b/saas/app/controllers/admin/stats_controller.rb @@ -0,0 +1,20 @@ +class Admin::StatsController < AdminController + layout "public" + + def show + @accounts_total = Account.count + @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count + @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count + + @identities_total = Identity.count + @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count + @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count + + @top_accounts = Account + .where("cards_count > 0") + .order(cards_count: :desc) + .limit(20) + + @recent_accounts = Account.order(created_at: :desc).limit(10) + end +end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 000000000..c76c4929b --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,12 @@ +module Card::Limited + extend ActiveSupport::Concern + + included do + before_action :ensure_can_create_cards, only: %i[ create ] + end + + private + def ensure_can_create_cards + head :forbidden if Current.account.exceeding_card_limit? + end +end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb new file mode 100644 index 000000000..31839e089 --- /dev/null +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -0,0 +1,83 @@ +class Stripe::WebhooksController < ApplicationController + allow_unauthenticated_access + skip_before_action :require_account + skip_before_action :verify_authenticity_token + + def create + event = verify_webhook_signature + return head :bad_request unless event + + dispatch_stripe_event(event) + + head :ok + end + + private + def dispatch_stripe_event(event) + case event.type + when "checkout.session.completed" + handle_checkout_completed(event.data.object) + when "customer.subscription.updated" + handle_subscription_updated(event.data.object) + when "customer.subscription.deleted" + handle_subscription_deleted(event.data.object) + when "invoice.payment_failed" + handle_payment_failed(event.data.object) + end + end + + def verify_webhook_signature + payload = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] + + Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]) + rescue Stripe::SignatureVerificationError => e + Rails.logger.error "Stripe webhook signature verification failed: #{e.message}" + nil + end + + def handle_checkout_completed(session) + return unless session.mode == "subscription" + + subscription = find_subscription_by_customer(session.customer) + return unless subscription + + stripe_subscription = Stripe::Subscription.retrieve(session.subscription) + + subscription.update! \ + stripe_subscription_id: stripe_subscription.id, + plan_key: session.metadata["plan_key"], + status: stripe_subscription.status, + current_period_end: extract_current_period_end(stripe_subscription) + end + + def handle_subscription_updated(stripe_subscription) + if subscription = find_subscription_by_customer(stripe_subscription.customer) + subscription.update! \ + status: stripe_subscription.status, + current_period_end: extract_current_period_end(stripe_subscription), + cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil + end + end + + def handle_subscription_deleted(stripe_subscription) + if subscription = find_subscription_by_customer(stripe_subscription.customer) + subscription.update!(status: "canceled", stripe_subscription_id: nil) + end + end + + def handle_payment_failed(invoice) + if subscription = find_subscription_by_customer(invoice.customer) + subscription.update!(status: "past_due") + end + end + + def find_subscription_by_customer(customer_id) + Account::Subscription.find_by(stripe_customer_id: customer_id) + end + + def extract_current_period_end(stripe_subscription) + timestamp = stripe_subscription.items.data.first&.current_period_end + Time.at(timestamp) if timestamp + end +end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb new file mode 100644 index 000000000..32dd45402 --- /dev/null +++ b/saas/app/helpers/subscriptions_helper.rb @@ -0,0 +1,11 @@ +module SubscriptionsHelper + def subscription_period_end_action(subscription) + if subscription.to_be_canceled? + "Your Fizzy subscription ends on" + elsif subscription.canceled? + "Your Fizzy subscription ended on" + else + "Your next payment of $#{ subscription.plan.price } will be billed on".html_safe + end + end +end diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb new file mode 100644 index 000000000..69c033817 --- /dev/null +++ b/saas/app/models/account/billing.rb @@ -0,0 +1,29 @@ +module Account::Billing + extend ActiveSupport::Concern + + included do + has_one :subscription, class_name: "Account::Subscription", dependent: :destroy + end + + NEAR_CARD_LIMIT_THRESHOLD = 100 + + def plan + active_subscription&.plan || Plan.free + end + + def active_subscription + subscription if subscription&.active? + end + + def subscribed? + subscription.present? + end + + def nearing_plan_cards_limit? + plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD + end + + def exceeding_card_limit? + cards_count > plan.card_limit + end +end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb new file mode 100644 index 000000000..cc4989343 --- /dev/null +++ b/saas/app/models/account/subscription.rb @@ -0,0 +1,17 @@ +class Account::Subscription < SaasRecord + belongs_to :account + + enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself) + + validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) } + + delegate :paid?, to: :plan + + def plan + Plan.find(plan_key) + end + + def to_be_canceled? + active? && cancel_at.present? + end +end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb new file mode 100644 index 000000000..e07bf73d8 --- /dev/null +++ b/saas/app/models/plan.rb @@ -0,0 +1,54 @@ +class Plan + PLANS = { + free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID", "price_1SaHykRwChFE4it8PePOdDpS") } + } + + attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id + + class << self + def all + @all ||= PLANS.map { |key, properties| new(key: key, **properties) } + end + + def free + @free ||= all.find(&:free?) + end + + def paid + @paid ||= all.find(&:paid?) + end + + def find(key) + @all_by_key ||= all.index_by(&:key).with_indifferent_access + @all_by_key[key] + end + + alias [] find + end + + def initialize(key:, name:, price:, card_limit:, storage_limit:, stripe_price_id: nil) + @key = key + @name = name + @price = price + @card_limit = card_limit + @storage_limit = storage_limit + @stripe_price_id = stripe_price_id + end + + def free? + price.zero? + end + + def paid? + !free? + end + + def limit_cards? + card_limit != Float::INFINITY + end + + def formatted_storage_limit + ActionController::Base.helpers.number_to_human_size(storage_limit).delete(" ") + end +end diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb new file mode 100644 index 000000000..3aef9b853 --- /dev/null +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -0,0 +1,7 @@ + + +<% if subscription.current_period_end %> +

<%= 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 } %> diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb new file mode 100644 index 000000000..d2b865ed8 --- /dev/null +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -0,0 +1,28 @@ +<% if Current.user.admin? %> +
+

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.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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+ + <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> + +

Cancel any time, no contracts, take your data with you whenever.

+ + <% else %> +

Thank you for buying Fizzy

+ + <% if Current.account.subscription %> + <%= render "account/settings/subscription", subscription: Current.account.subscription %> + + <% end %> + <% end %> + +
+<% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb new file mode 100644 index 000000000..136bec340 --- /dev/null +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -0,0 +1,6 @@ +
+ 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 Unlimitted + <% end %> +
diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb new file mode 100644 index 000000000..762bab720 --- /dev/null +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -0,0 +1,9 @@ +<% @page_title = "Thank you" %> + +<% if @session&.payment_status == "paid" %> +
+

Thanks for buying Fizzy!

+

Your payment was successful. You're now on the <%= Current.account.plan.name %> plan.

+ <%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %> +
+<% end %> diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb new file mode 100644 index 000000000..4245c8cd5 --- /dev/null +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -0,0 +1,14 @@ +

Edit Account <%= @account.external_account_id %>

+ +

Name: <%= @account.name %>

+ +<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %> +

+ <%= form.label :cards_count %> + <%= form.number_field :cards_count %> +

+ + <%= form.submit "Update" %> +<% end %> + +

<%= link_to "Back", saas.admin_accounts_path %>

diff --git a/saas/app/views/admin/accounts/index.html.erb b/saas/app/views/admin/accounts/index.html.erb new file mode 100644 index 000000000..e3faa98f2 --- /dev/null +++ b/saas/app/views/admin/accounts/index.html.erb @@ -0,0 +1,6 @@ +

Find Account

+ +<%= form_with url: saas.admin_account_search_path do |form| %> + <%= form.text_field :q, placeholder: "Account ID", autofocus: true %> + <%= form.submit "Search" %> +<% end %> diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb new file mode 100644 index 000000000..7c7491d2a --- /dev/null +++ b/saas/app/views/admin/stats/show.html.erb @@ -0,0 +1,126 @@ +<% @page_title = "Account Statistics" %> + +<% content_for :header do %> +

<%= @page_title %>

+<% end %> + +
+
+
+
+
+

Accounts Created

+
+
+
+
Total
+
+ <%= @accounts_total %> +
+
+
+
7 days
+
+ <%= @accounts_last_7_days %> +
+
+
+
24 hours
+
+ <%= @accounts_last_24_hours %> +
+
+
+
+ +
+
+

Identities Created

+
+
+
+
Total
+
+ <%= @identities_total %> +
+
+
+
7 days
+
+ <%= @identities_last_7_days %> +
+
+
+
24 hours
+
+ <%= @identities_last_24_hours %> +
+
+
+
+
+
+ +
+
+

+ 10 Most Recent Signups +

+
+ +
    + <% @recent_accounts.each do |account| %> + <% admin_user = account.users.owner.first %> +
  • +
    + <%= account.name %> +
    + + #<%= account.external_account_id %> • + <%= admin_user&.identity&.email_address || "No admin" %> + +
    + +
    + <%= time_ago_in_words(account.created_at) %> ago +
    +
  • + <% end %> +
+
+ +
+
+

+ Top 20 Accounts by Card Count +

+
+ +
    + <% @top_accounts.each do |account| %> + <% admin_user = account.users.owner.first %> +
  • +
    + <%= account.name %> +
    + + #<%= account.external_account_id %> • + <%= admin_user&.identity&.email_address || "No admin" %> + +
    + +
    + <%= number_with_delimiter(account.cards_count) %> + cards +
    +
  • + <% end %> +
+
+
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb new file mode 100644 index 000000000..295c9bd19 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/upgrade" %> +<% else %> + <%= render "cards/container/footer/create", card: card %> +<% end %> 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 new file mode 100644 index 000000000..b0b9c9170 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -0,0 +1,5 @@ +<% if Current.account.nearing_plan_cards_limit? %> +
+ You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimitted", account_settings_path(anchor: "subscription") %>. +
+<% end %> diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml index 8744705b4..4aefe5663 100644 --- a/saas/config/deploy.beta.yml +++ b/saas/config/deploy.beta.yml @@ -44,6 +44,9 @@ env: - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_SECRET tags: sc_chi: {} df_iad: diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml index d8e4dc4b9..70556948e 100644 --- a/saas/config/deploy.production.yml +++ b/saas/config/deploy.production.yml @@ -50,6 +50,9 @@ env: - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_SECRET tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml index 2abc18ecb..2c0ae9516 100644 --- a/saas/config/deploy.staging.yml +++ b/saas/config/deploy.staging.yml @@ -50,6 +50,9 @@ env: - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT + - STRIPE_MONTHLY_V1_PRICE_ID + - STRIPE_SECRET_KEY + - STRIPE_WEBHOOK_SECRET tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 7c7d4f6be..23ee39507 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -3,5 +3,8 @@ Fizzy::Saas::Engine.routes.draw do namespace :admin do mount Audits1984::Engine, at: "/console" + get "stats", to: "stats#show" + resource :account_search, only: :create + resources :accounts end end diff --git a/saas/db/migrate/20251203144630_create_account_subscriptions.rb b/saas/db/migrate/20251203144630_create_account_subscriptions.rb new file mode 100644 index 000000000..7da48060c --- /dev/null +++ b/saas/db/migrate/20251203144630_create_account_subscriptions.rb @@ -0,0 +1,15 @@ +class CreateAccountSubscriptions < ActiveRecord::Migration[8.2] + def change + create_table :account_subscriptions, id: :uuid do |t| + t.references :account, null: false, type: :uuid, index: true + t.string :plan_key + t.string :stripe_customer_id, null: false, index: { unique: true } + t.string :stripe_subscription_id, index: { unique: true } + t.string :status + t.datetime :current_period_end + t.datetime :cancel_at + + t.timestamps + end + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 19d468927..190a5e8f7 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,22 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_02_205753) do +ActiveRecord::Schema[8.2].define(version: 2025_12_03_144630) do + create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "cancel_at" + t.datetime "created_at", null: false + t.datetime "current_period_end" + t.string "plan_key" + t.string "status" + t.string "stripe_customer_id", null: false + t.string "stripe_subscription_id" + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_subscriptions_on_account_id" + t.index ["stripe_customer_id"], name: "index_account_subscriptions_on_stripe_customer_id", unique: true + t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true + end + create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "auditor_id", null: false t.datetime "created_at", null: false diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index 64f253588..d4f79e4ce 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| spec.metadata["source_code_uri"] = "https://github.com/basecamp/fizzy-saas" spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib,test}/**/*", "LICENSE.md", "Rakefile", "README.md"] + Dir["{app,config,db,lib}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] end spec.add_dependency "rails", ">= 8.1.0.beta1" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index ad23bef74..bf0914a76 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -9,6 +9,28 @@ module Fizzy # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy + initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app| + app.config.x.content_security_policy.form_action = "https://checkout.stripe.com" + end + + initializer "fizzy_saas.assets" do |app| + app.config.assets.paths << root.join("app/assets/stylesheets") + end + + initializer "fizzy.saas.routes", after: :add_routing_paths do |app| + # Routes that rely on the implicit account tenant should go here instead of in +routes.rb+. + app.routes.prepend do + namespace :account do + resource :billing_portal, only: :show + resource :subscription + end + + namespace :stripe do + resource :webhooks, only: :create + end + end + end + initializer "fizzy.saas.mount" do |app| app.routes.append do mount Fizzy::Saas::Engine => "/", as: "saas" @@ -47,6 +69,10 @@ module Fizzy end end + initializer "fizzy_saas.stripe" do + Stripe.api_key = ENV["STRIPE_SECRET_KEY"] + end + initializer "fizzy_saas.sentry" do if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? Sentry.init do |config| @@ -101,6 +127,8 @@ module Fizzy end config.to_prepare do + ::Account.include(Account::Billing) + ::CardsController.include(Card::Limited) ::Signup.prepend(Fizzy::Saas::Signup) Queenbee::Subscription.short_names = Subscription::SHORT_NAMES diff --git a/saas/lib/fizzy/saas/testing.rb b/saas/lib/fizzy/saas/testing.rb index eeaf24954..3b40f027a 100644 --- a/saas/lib/fizzy/saas/testing.rb +++ b/saas/lib/fizzy/saas/testing.rb @@ -7,3 +7,14 @@ Queenbee::Remote::Account.class_eval do super + Random.rand(1000000) end end + +# Add engine fixtures to the test fixture paths +module Fizzy::Saas::EngineFixtures + def included(base) + super + engine_fixtures = Fizzy::Saas::Engine.root.join("test", "fixtures").to_s + base.fixture_paths << engine_fixtures unless base.fixture_paths.include?(engine_fixtures) + end +end + +ActiveRecord::TestFixtures.singleton_class.prepend(Fizzy::Saas::EngineFixtures) diff --git a/saas/test/controllers/accounts/billing_portals_controller_test.rb b/saas/test/controllers/accounts/billing_portals_controller_test.rb new file mode 100644 index 000000000..00f16e4df --- /dev/null +++ b/saas/test/controllers/accounts/billing_portals_controller_test.rb @@ -0,0 +1,29 @@ +require "test_helper" +require "ostruct" + +class Account::BillingPortalsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "redirects to stripe billing portal" do + Current.account.subscription.update!(stripe_customer_id: "cus_test123") + + session = OpenStruct.new(url: "https://billing.stripe.com/session123") + Stripe::BillingPortal::Session.expects(:create) + .with(customer: "cus_test123", return_url: account_settings_url) + .returns(session) + + get account_billing_portal_path + + assert_redirected_to "https://billing.stripe.com/session123" + end + + test "requires admin" do + logout_and_sign_in_as :david + + get account_billing_portal_path + + assert_response :forbidden + end +end diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb new file mode 100644 index 000000000..9cf5d86bc --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -0,0 +1,47 @@ +require "test_helper" +require "ostruct" + +class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "show" do + get account_subscription_path + assert_response :success + end + + test "show with session_id retrieves stripe session" do + Stripe::Checkout::Session.stubs(:retrieve).with("sess_123").returns(OpenStruct.new(id: "sess_123")) + + get account_subscription_path(session_id: "sess_123") + assert_response :success + end + + test "show requires admin" do + logout_and_sign_in_as :david + + get account_subscription_path + assert_response :forbidden + end + + test "create redirects to stripe checkout" do + customer = OpenStruct.new(id: "cus_test_37signals") + session = OpenStruct.new(url: "https://checkout.stripe.com/session123") + + Stripe::Customer.stubs(:retrieve).returns(customer) + Stripe::Checkout::Session.stubs(:create).returns(session) + + post account_subscription_path + + assert_redirected_to "https://checkout.stripe.com/session123" + end + + test "create requires admin" do + logout_and_sign_in_as :david + + post account_subscription_path + assert_response :forbidden + end + +end diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb new file mode 100644 index 000000000..a6c815bcf --- /dev/null +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest + def saas + Fizzy::Saas::Engine.routes.url_helpers + end + + test "staff can access index" do + sign_in_as :david + + untenanted do + get saas.admin_accounts_path + end + + assert_response :success + end + + test "search account" do + sign_in_as :david + + untenanted do + post saas.admin_account_search_path, params: { q: accounts(:"37s").external_account_id } + assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) + end + end + + test "staff can edit account" do + sign_in_as :david + + untenanted do + get saas.edit_admin_account_path(accounts(:"37s").external_account_id) + end + + assert_response :success + end + + test "staff can update cards_count" do + sign_in_as :david + + untenanted do + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 500 } } + assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) + end + + assert_equal 500, accounts(:"37s").reload.cards_count + end + + test "non-staff cannot access accounts" do + sign_in_as :jz + + untenanted do + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 9999 } } + end + + assert_response :forbidden + assert_not_equal 9999, accounts(:"37s").reload.cards_count + end +end diff --git a/saas/test/controllers/admin/stats_controller_test.rb b/saas/test/controllers/admin/stats_controller_test.rb new file mode 100644 index 000000000..9fa0c8b3f --- /dev/null +++ b/saas/test/controllers/admin/stats_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Admin::StatsControllerTest < ActionDispatch::IntegrationTest + def saas + Fizzy::Saas::Engine.routes.url_helpers + end + + test "staff can access stats" do + sign_in_as :david + + untenanted do + get saas.admin_stats_path + end + + assert_response :success + end + + test "non-staff cannot access stats" do + sign_in_as :jz + + untenanted do + get saas.admin_stats_path + end + + assert_response :forbidden + end +end diff --git a/saas/test/controllers/card/limited_test.rb b/saas/test/controllers/card/limited_test.rb new file mode 100644 index 000000000..915cd1049 --- /dev/null +++ b/saas/test/controllers/card/limited_test.rb @@ -0,0 +1,15 @@ +require "test_helper" + +class Card::LimitedTest < ActionDispatch::IntegrationTest + test "cannot create cards when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) + end + + assert_response :forbidden + end +end diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb new file mode 100644 index 000000000..7bc4897dc --- /dev/null +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -0,0 +1,95 @@ +require "test_helper" +require "ostruct" + +class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest + setup do + @account = Account.create!(name: "Test") + @subscription = @account.create_subscription! \ + plan_key: "monthly_v1", + status: "incomplete", + stripe_customer_id: "cus_test123" + end + + test "invalid signature returns bad request" do + Stripe::Webhook.stubs(:construct_event).raises(Stripe::SignatureVerificationError.new("invalid", "sig")) + + post stripe_webhooks_path + assert_response :bad_request + end + + test "checkout session completed activates subscription" do + stripe_sub = OpenStruct.new(id: "sub_123", status: "active", items: stub_items(1.month.from_now.to_i)) + + event = stripe_event("checkout.session.completed", + mode: "subscription", + customer: "cus_test123", + subscription: "sub_123", + metadata: { "plan_key" => "monthly_v1" } + ) + + Stripe::Webhook.stubs(:construct_event).returns(event) + Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) + + post stripe_webhooks_path + + assert_response :ok + @subscription.reload + assert_equal "sub_123", @subscription.stripe_subscription_id + assert_equal "active", @subscription.status + end + + test "subscription updated changes status" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + event = stripe_event("customer.subscription.updated", + customer: "cus_test123", + status: "past_due", + cancel_at: nil, + items: stub_items(1.month.from_now.to_i) + ) + + Stripe::Webhook.stubs(:construct_event).returns(event) + + post stripe_webhooks_path + + assert_response :ok + assert_equal "past_due", @subscription.reload.status + end + + test "subscription deleted cancels subscription" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + event = stripe_event("customer.subscription.deleted", customer: "cus_test123") + + Stripe::Webhook.stubs(:construct_event).returns(event) + + post stripe_webhooks_path + + assert_response :ok + @subscription.reload + assert_equal "canceled", @subscription.status + assert_nil @subscription.stripe_subscription_id + end + + test "payment failed marks subscription as past due" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + event = stripe_event("invoice.payment_failed", customer: "cus_test123") + + Stripe::Webhook.stubs(:construct_event).returns(event) + + post stripe_webhooks_path + + assert_response :ok + assert_equal "past_due", @subscription.reload.status + end + + private + def stripe_event(type, **attributes) + OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes))) + end + + def stub_items(current_period_end) + OpenStruct.new(data: [ OpenStruct.new(current_period_end: current_period_end) ]) + end +end diff --git a/saas/test/fixtures/account_subscriptions.yml b/saas/test/fixtures/account_subscriptions.yml new file mode 100644 index 000000000..a18ff961b --- /dev/null +++ b/saas/test/fixtures/account_subscriptions.yml @@ -0,0 +1,11 @@ +_fixture: + model_class: Account::Subscription + +signals_monthly: + id: <%= ActiveRecord::FixtureSet.identify("signals_monthly", :uuid) %> + account_id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %> + plan_key: monthly_v1 + status: active + stripe_customer_id: cus_test_37signals + created_at: <%= Time.current %> + updated_at: <%= Time.current %> diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb new file mode 100644 index 000000000..086f60807 --- /dev/null +++ b/saas/test/models/account/billing_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class Account::BillingTest < ActiveSupport::TestCase + test "active subscription" do + account = Account.create!(name: "Test") + + # No subscription + assert_nil account.active_subscription + + # Subscription but it is not active + account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test") + assert_nil account.active_subscription + + # Active subscription exists + account.subscription.update!(status: "active") + assert_equal account.subscription, account.active_subscription + end + + test "detect nearing card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").nearing_plan_cards_limit? + + # Free plan not near limit + accounts(:initech).update_column(:cards_count, 899) + assert_not accounts(:initech).nearing_plan_cards_limit? + + # Free plan near limit + accounts(:initech).update_column(:cards_count, 900) + assert_not accounts(:initech).nearing_plan_cards_limit? + + accounts(:initech).update_column(:cards_count, 901) + assert accounts(:initech).nearing_plan_cards_limit? + end + + test "detect exceeding card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").exceeding_card_limit? + + # Free plan under limit + accounts(:initech).update_column(:cards_count, 999) + assert_not accounts(:initech).exceeding_card_limit? + + # Free plan over limit + accounts(:initech).update_column(:cards_count, 1001) + assert accounts(:initech).exceeding_card_limit? + end +end diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb new file mode 100644 index 000000000..99612ecea --- /dev/null +++ b/saas/test/models/account/subscription_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class Account::SubscriptionTest < ActiveSupport::TestCase + test "get the account plan" do + subscription = Account::Subscription.new(plan_key: "free_v1") + assert_equal Plan[:free_v1], subscription.plan + end + + test "check if account is active" do + subscription = Account::Subscription.new(status: "active") + assert subscription.active? + end + + test "check if account is paid" do + assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid? + assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid? + end +end diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb new file mode 100644 index 000000000..6e0d70b5c --- /dev/null +++ b/saas/test/models/plan_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class PlanTest < ActiveSupport::TestCase + test "free plan is free" do + assert Plan[:free_v1].free? + end + + test "monthly plan is not free" do + assert_not Plan[:monthly_v1].free? + end +end From 5be4dca80e1dc4283e61f1ff8e80cf22629be63c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 09:25:41 +0100 Subject: [PATCH 02/36] Refresh the subscription instead of relying on event payloads Events can land out of order. See https://github.com/basecamp/fizzy-saas/pull/23#discussion_r2609909558 --- .../controllers/stripe/webhooks_controller.rb | 56 +++++++------------ .../stripe/webhooks_controller_test.rb | 32 +++++------ 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 31839e089..14c50c74d 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -15,14 +15,10 @@ class Stripe::WebhooksController < ApplicationController private def dispatch_stripe_event(event) case event.type - when "checkout.session.completed" - handle_checkout_completed(event.data.object) - when "customer.subscription.updated" - handle_subscription_updated(event.data.object) - when "customer.subscription.deleted" - handle_subscription_deleted(event.data.object) - when "invoice.payment_failed" - handle_payment_failed(event.data.object) + when "checkout.session.completed" + sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription" + when "customer.subscription.updated", "customer.subscription.deleted" + sync_subscription(event.data.object.id) end end @@ -36,39 +32,29 @@ class Stripe::WebhooksController < ApplicationController nil end - def handle_checkout_completed(session) - return unless session.mode == "subscription" - - subscription = find_subscription_by_customer(session.customer) - return unless subscription - - stripe_subscription = Stripe::Subscription.retrieve(session.subscription) - - subscription.update! \ - stripe_subscription_id: stripe_subscription.id, - plan_key: session.metadata["plan_key"], - status: stripe_subscription.status, - current_period_end: extract_current_period_end(stripe_subscription) + def sync_new_subscription(stripe_subscription_id, plan_key:) + sync_subscription(stripe_subscription_id) do |subscription_properties| + subscription_properties[:plan_key] = plan_key if plan_key + end end - def handle_subscription_updated(stripe_subscription) + # Always fetch fresh subscription data from Stripe to handle out-of-order + # event delivery. Not relying on payload data. + def sync_subscription(stripe_subscription_id) + stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id) + if subscription = find_subscription_by_customer(stripe_subscription.customer) - subscription.update! \ + subscription_properties = { + stripe_subscription_id: stripe_subscription.id, status: stripe_subscription.status, - current_period_end: extract_current_period_end(stripe_subscription), + current_period_end: current_period_end_for(stripe_subscription), cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil - end - end + } - def handle_subscription_deleted(stripe_subscription) - if subscription = find_subscription_by_customer(stripe_subscription.customer) - subscription.update!(status: "canceled", stripe_subscription_id: nil) - end - end + yield subscription_properties if block_given? + subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled" - def handle_payment_failed(invoice) - if subscription = find_subscription_by_customer(invoice.customer) - subscription.update!(status: "past_due") + subscription.update!(subscription_properties) end end @@ -76,7 +62,7 @@ class Stripe::WebhooksController < ApplicationController Account::Subscription.find_by(stripe_customer_id: customer_id) end - def extract_current_period_end(stripe_subscription) + def current_period_end_for(stripe_subscription) timestamp = stripe_subscription.items.data.first&.current_period_end Time.at(timestamp) if timestamp end diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb index 7bc4897dc..c1c8ac4d0 100644 --- a/saas/test/controllers/stripe/webhooks_controller_test.rb +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -18,7 +18,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest end test "checkout session completed activates subscription" do - stripe_sub = OpenStruct.new(id: "sub_123", status: "active", items: stub_items(1.month.from_now.to_i)) + stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i)) event = stripe_event("checkout.session.completed", mode: "subscription", @@ -41,14 +41,18 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest test "subscription updated changes status" do @subscription.update!(stripe_subscription_id: "sub_123", status: "active") - event = stripe_event("customer.subscription.updated", + stripe_sub = OpenStruct.new( + id: "sub_123", customer: "cus_test123", status: "past_due", cancel_at: nil, items: stub_items(1.month.from_now.to_i) ) + event = stripe_event("customer.subscription.updated", id: "sub_123") + Stripe::Webhook.stubs(:construct_event).returns(event) + Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) post stripe_webhooks_path @@ -59,9 +63,18 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest test "subscription deleted cancels subscription" do @subscription.update!(stripe_subscription_id: "sub_123", status: "active") - event = stripe_event("customer.subscription.deleted", customer: "cus_test123") + stripe_sub = OpenStruct.new( + id: "sub_123", + customer: "cus_test123", + status: "canceled", + cancel_at: nil, + items: stub_items(1.month.from_now.to_i) + ) + + event = stripe_event("customer.subscription.deleted", id: "sub_123") Stripe::Webhook.stubs(:construct_event).returns(event) + Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) post stripe_webhooks_path @@ -71,19 +84,6 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest assert_nil @subscription.stripe_subscription_id end - test "payment failed marks subscription as past due" do - @subscription.update!(stripe_subscription_id: "sub_123", status: "active") - - event = stripe_event("invoice.payment_failed", customer: "cus_test123") - - Stripe::Webhook.stubs(:construct_event).returns(event) - - post stripe_webhooks_path - - assert_response :ok - assert_equal "past_due", @subscription.reload.status - end - private def stripe_event(type, **attributes) OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes))) From a25bd9adfde2ba298f59b19d6953b766fa8dfd45 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 10:34:57 +0100 Subject: [PATCH 03/36] Let's use a single sandbox environment via 1password ENV vars --- saas/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index e07bf73d8..24e5cc633 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID", "price_1SaHykRwChFE4it8PePOdDpS") } + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID") } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id From 90eb61bb5f39345c15c058509f98bbf3eb8f07c7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 10:54:09 +0100 Subject: [PATCH 04/36] Add script to set stripe env vars from 1password --- saas/exe/stripe-dev | 23 +++++++++++++++++++++++ saas/fizzy-saas.gemspec | 5 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100755 saas/exe/stripe-dev diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev new file mode 100755 index 000000000..820264766 --- /dev/null +++ b/saas/exe/stripe-dev @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# +# Fetches Stripe development environment variables from 1Password. +# Usage: eval "$(bundle exec stripe-dev)" + +require "json" + +secrets_escaped = `kamal secrets fetch \ + --adapter 1password \ + --account basecamp \ + --from "Deploy/Fizzy" \ + "Development/STRIPE_SECRET_KEY" \ + "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` + +# kamal outputs shell-escaped JSON, unescape it +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"] + +puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}") +puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}") diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec index d4f79e4ce..1368ef63c 100644 --- a/saas/fizzy-saas.gemspec +++ b/saas/fizzy-saas.gemspec @@ -18,9 +18,12 @@ Gem::Specification.new do |spec| spec.metadata["source_code_uri"] = "https://github.com/basecamp/fizzy-saas" spec.files = Dir.chdir(File.expand_path(__dir__)) do - Dir["{app,config,db,lib}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] + Dir["{app,config,db,lib,exe}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] end + spec.bindir = "exe" + spec.executables = [ "stripe-dev" ] + spec.add_dependency "rails", ">= 8.1.0.beta1" spec.add_dependency "queenbee" spec.add_dependency "rails_structured_logging" From 5baf9d879d2a7997353cbfeda5e433e3de08a1b8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 11:12:25 +0100 Subject: [PATCH 05/36] Dont't fail if not set --- saas/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 24e5cc633..0d8506da7 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID") } + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id From fc0dc9423dae1712f655a6a8633994f34e2c378c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 11:12:50 +0100 Subject: [PATCH 06/36] Kill previous tunnel automatically --- saas/exe/stripe-dev | 61 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index 820264766..c2aea61a4 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -1,10 +1,30 @@ #!/usr/bin/env ruby # -# Fetches Stripe development environment variables from 1Password. +# Fetches Stripe development environment variables from 1Password and starts +# the Stripe CLI webhook listener. +# # Usage: eval "$(bundle exec stripe-dev)" require "json" +require "fileutils" +LOG_FILE = "log/stripe.development.log" +PID_FILE = "tmp/stripe.tunnel.pid" + +# Ensure directories exist +FileUtils.mkdir_p("log") +FileUtils.mkdir_p("tmp") + +# Kill any existing stripe tunnel process +if File.exist?(PID_FILE) + old_pid = File.read(PID_FILE).strip.to_i + if old_pid > 0 + Process.kill("TERM", old_pid) rescue nil + end + File.delete(PID_FILE) +end + +# Fetch secrets from 1Password secrets_escaped = `kamal secrets fetch \ --adapter 1password \ --account basecamp \ @@ -12,12 +32,49 @@ secrets_escaped = `kamal secrets fetch \ "Development/STRIPE_SECRET_KEY" \ "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` -# kamal outputs shell-escaped JSON, unescape it secrets_json = secrets_escaped.gsub(/\\(.)/, '\1') secrets = JSON.parse(secrets_json) stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"] stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"] +# Clear previous log file +File.write(LOG_FILE, "") + +# Start stripe listen in background +pid = spawn( + "stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks", + out: [LOG_FILE, "a"], + err: [LOG_FILE, "a"] +) +Process.detach(pid) + +# Save PID for cleanup +File.write(PID_FILE, pid.to_s) + +# Wait for the webhook secret to appear in logs +webhook_secret = nil +20.times do + sleep 0.5 + if File.exist?(LOG_FILE) + content = File.read(LOG_FILE) + if match = content.match(/webhook signing secret is (whsec_\w+)/) + webhook_secret = match[1] + break + end + end +end + +if webhook_secret.nil? + warn "Warning: Could not capture webhook secret from stripe listen" +end + +# Output export statements puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}") puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}") +puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret + +# Informational message to stderr (won't be eval'd) +warn "" +warn "Stripe CLI listening (PID: #{pid})" +warn "Logs: #{LOG_FILE}" From c34e29454eb548007b8f9ba2997195fc456db018 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 14:21:07 +0100 Subject: [PATCH 07/36] Document how to work with Stripe locally --- saas/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/saas/README.md b/saas/README.md index 90bf7096d..9603c317d 100644 --- a/saas/README.md +++ b/saas/README.md @@ -24,6 +24,22 @@ After making changes to this gem, you need to update Fizzy to pick up the change BUNDLE_GEMFILE=Gemfile.saas bundle update --conservative fizzy-saas ``` +## Working with Stripe + +The first time, you need to: + +1. Install Stripe CLI: https://stripe.com/docs/stripe-cli +2. Run `stripe login` and authorize the environment `37signals Development` + +Then, for working on the Stripe integration locally, you need to run this script to start the tunneling and set the environment variables: + +```sh +eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec stripe-dev)" +bin/dev # You need to start the dev server in the same terminal session +``` + +This will ask for your 1password authorization to read and set the environment variables that Stripe needs. + ## Environments Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. From 623d80017fbf93ba213511e4b1e027644e08e05a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 15:00:15 +0100 Subject: [PATCH 08/36] Add stripe envs --- saas/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/saas/README.md b/saas/README.md index 9603c317d..80ff67a36 100644 --- a/saas/README.md +++ b/saas/README.md @@ -40,6 +40,12 @@ bin/dev # You need to start the dev server in the same terminal session This will ask for your 1password authorization to read and set the environment variables that Stripe needs. +### Stripe environments + +* [Development](https://dashboard.stripe.com/acct_1SdTFtRus34tgjsJ/test/dashboard) +* [Staging](https://dashboard.stripe.com/acct_1SdTbuRvb8txnPBR/test/dashboard) +* [Production](https://dashboard.stripe.com/acct_1SNy97RwChFE4it8/dashboard) + ## Environments Fizzy is deployed with [Kamal](https://kamal-deploy.org/). You'll need to have the 1Password CLI set up in order to access the secrets that are used when deploying. Provided you have that, it should be as simple as `bin/kamal deploy` to the correct environment. From ca5095cafdbf9a67b4b1dc050cbc232a15662774 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 15:56:55 +0100 Subject: [PATCH 09/36] Fix typo with unlimitted --- saas/app/views/account/settings/_subscription_panel.html.erb | 4 ++-- .../views/cards/container/footer/saas/_near_notice.html.erb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index d2b865ed8..a29b85194 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -14,13 +14,13 @@ <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel any time, no contracts, take your data with you whenever.

- + <% else %>

Thank you for buying Fizzy

<% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - + <% end %> <% end %> 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 b0b9c9170..9afdcb131 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,5 +1,5 @@ <% if Current.account.nearing_plan_cards_limit? %>
- You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimitted", account_settings_path(anchor: "subscription") %>. + You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>.
<% end %> From d5018cab2388e5bec230d67ec4cc02fdbd51dfc8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 16:10:45 +0100 Subject: [PATCH 10/36] Fix typo --- saas/app/views/account/settings/_subscription_panel.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index a29b85194..4837974ad 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -13,7 +13,7 @@ <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> -

Cancel any time, no contracts, take your data with you whenever.

+

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

<% else %>

Thank you for buying Fizzy

From e53455d2cabd6a3e047b3bb6275f6ece6006ac6b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 12 Dec 2025 17:00:40 +0100 Subject: [PATCH 11/36] Paid plans never exceed limits! --- saas/app/models/account/billing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 69c033817..be938701d 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -24,6 +24,6 @@ module Account::Billing end def exceeding_card_limit? - cards_count > plan.card_limit + plan.limit_cards? && cards_count > plan.card_limit end end From d556f051a694d2aeb7d0b87fa141016e319a1bc3 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:16:13 -0600 Subject: [PATCH 12/36] Spelling --- saas/app/models/plan.rb | 2 +- saas/app/views/account/subscriptions/_upgrade.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 0d8506da7..77a2cfbc7 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["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"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index 136bec340..c7aa27863 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,6 +1,6 @@
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 Unlimitted + Upgrade to Unlimited <% end %>
From 99535085410281c48edf2568950144bda1111703 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:27:30 -0600 Subject: [PATCH 13/36] Fancy apostrophes --- .../app/views/account/settings/_subscription_panel.html.erb | 6 +++--- saas/app/views/account/subscriptions/_upgrade.html.erb | 2 +- saas/app/views/account/subscriptions/show.html.erb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 4837974ad..48bcc100a 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -9,18 +9,18 @@

You've used <%= Current.account.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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

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

- + <% else %>

Thank you for buying Fizzy

<% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - + <% end %> <% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index c7aa27863..3a8fa126b 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,5 +1,5 @@
- You've used your <%= Plan.free.card_limit %> free cards. + 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/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb index 762bab720..7f3c18ffb 100644 --- a/saas/app/views/account/subscriptions/show.html.erb +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -3,7 +3,7 @@ <% if @session&.payment_status == "paid" %>

Thanks for buying Fizzy!

-

Your payment was successful. You're now on the <%= Current.account.plan.name %> plan.

+

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
<% end %> From 5957ed64494bdd95fc20fb6b313349501a0920a9 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:29:00 -0600 Subject: [PATCH 14/36] Typo --- saas/app/views/account/settings/_subscription_panel.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 48bcc100a..ae2a86ac5 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -11,7 +11,7 @@

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

- <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> + <%= 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.

From b1a8b3c859f147d5ca1d199c9715fb46ab9cea23 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:32:47 -0600 Subject: [PATCH 15/36] Layout --- saas/app/views/account/subscriptions/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb index 7f3c18ffb..64987b5d0 100644 --- a/saas/app/views/account/subscriptions/show.html.erb +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -1,7 +1,7 @@ <% @page_title = "Thank you" %> <% if @session&.payment_status == "paid" %> -
+

Thanks for buying Fizzy!

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %> From 6411268d7a56fa7a60e20342c1d1e5e66748d79c Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 13:40:37 -0600 Subject: [PATCH 16/36] More fancy apostrophes --- .../app/views/account/settings/_subscription_panel.html.erb | 6 +++--- .../views/cards/container/footer/saas/_near_notice.html.erb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index ae2a86ac5..58fb60891 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -4,12 +4,12 @@ <% if Current.account.plan.free? %> <% if Current.account.exceeding_card_limit? %> -

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

+

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

<% else %> -

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

+

You’ve used <%= Current.account.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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> 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 } } %> 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 9afdcb131..5b7ee42b3 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,5 +1,5 @@ <% if Current.account.nearing_plan_cards_limit? %>
- You've used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. + You’ve used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>.
<% end %> From b19770c2a81bb024e8ad87ea267a558d1f23d2cd Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 15:50:24 -0600 Subject: [PATCH 17/36] Show different things to non-admins --- saas/app/views/account/subscriptions/_upgrade.html.erb | 10 +++++++--- .../cards/container/footer/saas/_near_notice.html.erb | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index 3a8fa126b..a23abce0a 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,6 +1,10 @@
- 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 + <% 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 %> + <% else %> + This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. <% end %>
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 5b7ee42b3..7b34f7404 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,5 +1,9 @@ <% if Current.account.nearing_plan_cards_limit? %>
- You’ve used <%= Current.account.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. + <% if Current.user.admin? %> + You’ve used <%= Current.account.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.cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon + <% end %>
<% end %> From 7a5e29699c7f65295b5abb4a3b8e24893b8d2723 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 12 Dec 2025 16:18:26 -0600 Subject: [PATCH 18/36] Give this some padding --- saas/app/views/account/subscriptions/_upgrade.html.erb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index a23abce0a..d1c06e7f0 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -5,6 +5,8 @@ Upgrade to Unlimited <% end %> <% else %> - This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
+ This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
<% end %>
From c8330a7be86c568939ffd8a95f4a50cf4e7c3f0c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 11:07:14 +0100 Subject: [PATCH 19/36] Don't prevent creating drafts, or you won't get to see the upgrade banner Instead: - Always let you create drafts when pressing "Add card" - Prevent creations of published cards via API - Prevent publication of cards in all cases when the limit is exceeded --- .../concerns/card/limited_creation.rb | 14 ++++++ .../{limited.rb => limited_publishing.rb} | 6 +-- saas/lib/fizzy/saas/engine.rb | 3 +- .../controllers/card/limited_creation_test.rb | 43 +++++++++++++++++++ .../card/limited_publishing_test.rb | 14 ++++++ saas/test/controllers/card/limited_test.rb | 15 ------- 6 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 saas/app/controllers/concerns/card/limited_creation.rb rename saas/app/controllers/concerns/card/{limited.rb => limited_publishing.rb} (52%) create mode 100644 saas/test/controllers/card/limited_creation_test.rb create mode 100644 saas/test/controllers/card/limited_publishing_test.rb delete mode 100644 saas/test/controllers/card/limited_test.rb diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb new file mode 100644 index 000000000..b4c773449 --- /dev/null +++ b/saas/app/controllers/concerns/card/limited_creation.rb @@ -0,0 +1,14 @@ +module Card::LimitedCreation + extend ActiveSupport::Concern + + included do + # Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count. + # We limit card publications separately. See +Card::LimitedPublishing+. + before_action :ensure_can_create_cards, only: %i[ create ], if: -> { request.format.json? } + end + + private + def ensure_can_create_cards + head :forbidden if Current.account.exceeding_card_limit? + end +end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited_publishing.rb similarity index 52% rename from saas/app/controllers/concerns/card/limited.rb rename to saas/app/controllers/concerns/card/limited_publishing.rb index c76c4929b..b92ab06f3 100644 --- a/saas/app/controllers/concerns/card/limited.rb +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -1,12 +1,12 @@ -module Card::Limited +module Card::LimitedPublishing extend ActiveSupport::Concern included do - before_action :ensure_can_create_cards, only: %i[ create ] + before_action :ensure_can_publish_cards, only: %i[ create ] end private - def ensure_can_create_cards + def ensure_can_publish_cards head :forbidden if Current.account.exceeding_card_limit? end end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index bf0914a76..512e4884e 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -128,8 +128,9 @@ module Fizzy config.to_prepare do ::Account.include(Account::Billing) - ::CardsController.include(Card::Limited) ::Signup.prepend(Fizzy::Saas::Signup) + CardsController.include(Card::LimitedCreation) + Cards::PublishesController.include(Card::LimitedPublishing) Queenbee::Subscription.short_names = Subscription::SHORT_NAMES diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb new file mode 100644 index 000000000..b3477735c --- /dev/null +++ b/saas/test/controllers/card/limited_creation_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Card::LimitedCreationTest < ActionDispatch::IntegrationTest + test "cannot create cards via JSON when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json) + end + + assert_response :forbidden + end + + test "can create cards via HTML when card limit exceeded but they are drafts" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all + + assert_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) + end + + assert_response :redirect + assert Card.last.drafted? + end + + test "cannot force published status via HTML when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all + + assert_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { status: "published" } } + end + + assert_response :redirect + assert Card.last.drafted? + end +end diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb new file mode 100644 index 000000000..2eb57471e --- /dev/null +++ b/saas/test/controllers/card/limited_publishing_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest + test "cannot publish cards when card limit exceeded" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :forbidden + assert cards(:unfinished_thoughts).reload.drafted? + end +end diff --git a/saas/test/controllers/card/limited_test.rb b/saas/test/controllers/card/limited_test.rb deleted file mode 100644 index 915cd1049..000000000 --- a/saas/test/controllers/card/limited_test.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "test_helper" - -class Card::LimitedTest < ActionDispatch::IntegrationTest - test "cannot create cards when card limit exceeded" do - sign_in_as :mike - - accounts(:initech).update_column(:cards_count, 1001) - - assert_no_difference -> { Card.count } do - post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) - end - - assert_response :forbidden - end -end From f4184e5a612ba97d343b7fbe7700abb57161e7df Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 11:15:30 +0100 Subject: [PATCH 20/36] Remove redundant condition --- saas/app/models/account/billing.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index be938701d..69c033817 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -24,6 +24,6 @@ module Account::Billing end def exceeding_card_limit? - plan.limit_cards? && cards_count > plan.card_limit + cards_count > plan.card_limit end end From 725e850802cf384b101915876bd3ca7fd5c33071 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 11:55:13 +0100 Subject: [PATCH 21/36] Enable automatic taxes and calculate based on mandatory billing address https://app.fizzy.do/5986089/cards/3465 --- saas/app/controllers/account/subscriptions_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 5a7f27fcd..1e4c6d87e 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -12,7 +12,10 @@ class Account::SubscriptionsController < ApplicationController line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ], success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", cancel_url: account_subscription_url, - metadata: { account_id: Current.account.id, plan_key: Plan.paid.key } + metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }, + automatic_tax: { enabled: true }, + billing_address_collection: "required", + customer_update: { address: "auto" } redirect_to session.url, allow_other_host: true end From 3ef5e4eeef3552916a0d7bfbeefee60fd9668fda Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 14:49:47 +0100 Subject: [PATCH 22/36] Add record to track overridden limits Before, we were relying on just changing the cards_count in account, but this could create problems where the system to calculate the next card number fails due to the unique constraint. --- .../controllers/admin/accounts_controller.rb | 10 ++-- .../admin/overridden_limits_controller.rb | 8 +++ .../concerns/admin/account_scoped.rb | 12 +++++ saas/app/models/account/billing.rb | 10 ---- saas/app/models/account/limited.rb | 34 +++++++++++++ saas/app/models/account/overridden_limits.rb | 4 ++ .../settings/_subscription_panel.html.erb | 2 +- saas/app/views/admin/accounts/edit.html.erb | 39 ++++++++++---- .../footer/saas/_near_notice.html.erb | 4 +- saas/config/routes.rb | 4 +- ...140000_create_account_overridden_limits.rb | 10 ++++ saas/db/saas_schema.rb | 10 +++- saas/lib/fizzy/saas/engine.rb | 4 +- saas/lib/tasks/fizzy/saas_tasks.rake | 7 --- .../admin/accounts_controller_test.rb | 10 ++-- .../overridden_limits_controller_test.rb | 22 ++++++++ .../admin/stats_controller_test.rb | 4 -- saas/test/models/account/billing_test.rb | 31 ----------- saas/test/models/account/limited_test.rb | 51 +++++++++++++++++++ 19 files changed, 196 insertions(+), 80 deletions(-) create mode 100644 saas/app/controllers/admin/overridden_limits_controller.rb create mode 100644 saas/app/controllers/concerns/admin/account_scoped.rb create mode 100644 saas/app/models/account/limited.rb create mode 100644 saas/app/models/account/overridden_limits.rb create mode 100644 saas/db/migrate/20251215140000_create_account_overridden_limits.rb create mode 100644 saas/test/controllers/admin/overridden_limits_controller_test.rb create mode 100644 saas/test/models/account/limited_test.rb diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb index dda0794e4..ba6200d1b 100644 --- a/saas/app/controllers/admin/accounts_controller.rb +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -1,4 +1,6 @@ class Admin::AccountsController < AdminController + include Admin::AccountScoped + layout "public" before_action :set_account, only: %i[ edit update ] @@ -10,8 +12,8 @@ class Admin::AccountsController < AdminController end def update - @account.update!(account_params) - redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated" + @account.override_limits(card_count: overridden_card_count_param) + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated" end private @@ -19,7 +21,7 @@ class Admin::AccountsController < AdminController @account = Account.find_by!(external_account_id: params[:id]) end - def account_params - params.expect(account: [ :cards_count ]) + def overridden_card_count_param + params[:account][:overridden_card_count].to_i end end diff --git a/saas/app/controllers/admin/overridden_limits_controller.rb b/saas/app/controllers/admin/overridden_limits_controller.rb new file mode 100644 index 000000000..fff8ea3e3 --- /dev/null +++ b/saas/app/controllers/admin/overridden_limits_controller.rb @@ -0,0 +1,8 @@ +class Admin::OverriddenLimitsController < AdminController + include Admin::AccountScoped + + def destroy + @account.reset_overridden_limits + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset" + end +end diff --git a/saas/app/controllers/concerns/admin/account_scoped.rb b/saas/app/controllers/concerns/admin/account_scoped.rb new file mode 100644 index 000000000..314720cd2 --- /dev/null +++ b/saas/app/controllers/concerns/admin/account_scoped.rb @@ -0,0 +1,12 @@ +module Admin::AccountScoped + extend ActiveSupport::Concern + + included do + before_action :set_account + end + + private + def set_account + @account = Account.find_by!(external_account_id: params[:account_id] || params[:id]) + end +end diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 69c033817..2ef42edb7 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -5,8 +5,6 @@ module Account::Billing has_one :subscription, class_name: "Account::Subscription", dependent: :destroy end - NEAR_CARD_LIMIT_THRESHOLD = 100 - def plan active_subscription&.plan || Plan.free end @@ -18,12 +16,4 @@ module Account::Billing def subscribed? subscription.present? end - - def nearing_plan_cards_limit? - plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD - end - - def exceeding_card_limit? - cards_count > plan.card_limit - end end diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb new file mode 100644 index 000000000..6fed72e9e --- /dev/null +++ b/saas/app/models/account/limited.rb @@ -0,0 +1,34 @@ +module Account::Limited + extend ActiveSupport::Concern + + included do + has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy + end + + NEAR_CARD_LIMIT_THRESHOLD = 100 + + def override_limits(card_count:) + if overridden_limits + overridden_limits.update(card_count: card_count) + else + create_overridden_limits(card_count: card_count) + end + end + + def billed_cards_count + overridden_limits&.card_count || cards_count + end + + def nearing_plan_cards_limit? + plan.limit_cards? && (plan.card_limit - billed_cards_count) < NEAR_CARD_LIMIT_THRESHOLD + end + + def exceeding_card_limit? + plan.limit_cards? && billed_cards_count > plan.card_limit + end + + def reset_overridden_limits + overridden_limits&.destroy + reload_overridden_limits + end +end diff --git a/saas/app/models/account/overridden_limits.rb b/saas/app/models/account/overridden_limits.rb new file mode 100644 index 000000000..cdf3c51ec --- /dev/null +++ b/saas/app/models/account/overridden_limits.rb @@ -0,0 +1,4 @@ +# To ease testing of limits +class Account::OverriddenLimits < SaasRecord + belongs_to :account +end diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 58fb60891..52479f0b1 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -6,7 +6,7 @@ <% if Current.account.exceeding_card_limit? %>

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

<% else %> -

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

+

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 4245c8cd5..1a2f90515 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -1,14 +1,33 @@ -

Edit Account <%= @account.external_account_id %>

+
+
+

Edit Account <%= @account.external_account_id %>

-

Name: <%= @account.name %>

+
+
+
Name:
+
<%= @account.name %>
+
+
+
Actual card count:
+
<%= @account.cards_count %>
+
+
-<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %> -

- <%= form.label :cards_count %> - <%= form.number_field :cards_count %> -

+ <%= 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.submit "Update" %> -<% end %> + + <% end %> -

<%= link_to "Back", saas.admin_accounts_path %>

+ <% if @account.overridden_limits %> + <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> + <% end %> + + <%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %> +
+
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 7b34f7404..e40aa9fb5 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.cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. + 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.cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon + This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon <% end %>
<% end %> diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 23ee39507..7f9d36465 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -5,6 +5,8 @@ Fizzy::Saas::Engine.routes.draw do mount Audits1984::Engine, at: "/console" get "stats", to: "stats#show" resource :account_search, only: :create - resources :accounts + resources :accounts do + resource :overridden_limits, only: :destroy + end end end diff --git a/saas/db/migrate/20251215140000_create_account_overridden_limits.rb b/saas/db/migrate/20251215140000_create_account_overridden_limits.rb new file mode 100644 index 000000000..7e8d9da42 --- /dev/null +++ b/saas/db/migrate/20251215140000_create_account_overridden_limits.rb @@ -0,0 +1,10 @@ +class CreateAccountOverriddenLimits < ActiveRecord::Migration[8.2] + def change + create_table :account_overridden_limits, id: :uuid do |t| + t.references :account, null: false, type: :uuid, index: { unique: true } + t.integer :card_count + + t.timestamps + end + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 190a5e8f7..edfead6ad 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,15 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_03_144630) do +ActiveRecord::Schema[8.2].define(version: 2025_12_15_140000) 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.integer "card_count" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_overridden_limits_on_account_id", unique: true + end + create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "cancel_at" diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 512e4884e..532f0a384 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -127,8 +127,8 @@ module Fizzy end config.to_prepare do - ::Account.include(Account::Billing) - ::Signup.prepend(Fizzy::Saas::Signup) + ::Account.include Account::Billing, Account::Limited + ::Signup.prepend Fizzy::Saas::Signup CardsController.include(Card::LimitedCreation) Cards::PublishesController.include(Card::LimitedPublishing) diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake index 6b423e3c3..ada09dabd 100644 --- a/saas/lib/tasks/fizzy/saas_tasks.rake +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -1,13 +1,6 @@ require "rake/testtask" namespace :test do - # task :prepare_saas => :environment do - # require "rails/test_help" - # - # $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s - # require Fizzy::Saas::Engine.root.join("test/test_helper") - # end - desc "Run tests for fizzy-saas gem" Rake::TestTask.new(saas: :environment) do |t| t.libs << "test" diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb index a6c815bcf..0b830919a 100644 --- a/saas/test/controllers/admin/accounts_controller_test.rb +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -1,10 +1,6 @@ require "test_helper" class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest - def saas - Fizzy::Saas::Engine.routes.url_helpers - end - test "staff can access index" do sign_in_as :david @@ -34,15 +30,15 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end - test "staff can update cards_count" do + test "staff can override card count" do sign_in_as :david untenanted do - patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 500 } } + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } } assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) end - assert_equal 500, accounts(:"37s").reload.cards_count + assert_equal 500, accounts(:"37s").reload.billed_cards_count end test "non-staff cannot access accounts" do diff --git a/saas/test/controllers/admin/overridden_limits_controller_test.rb b/saas/test/controllers/admin/overridden_limits_controller_test.rb new file mode 100644 index 000000000..b660e08b0 --- /dev/null +++ b/saas/test/controllers/admin/overridden_limits_controller_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class Admin::OverriddenLimitsControllerTest < ActionDispatch::IntegrationTest + test "staff can reset overridden limits" do + sign_in_as :david + account = accounts(:"37s") + + # First set an override + account.override_limits(card_count: 500) + assert_equal 500, account.reload.billed_cards_count + + # Then reset it + untenanted do + delete saas.admin_account_overridden_limits_path(account.external_account_id) + assert_redirected_to saas.edit_admin_account_path(account.external_account_id) + end + + # Verify override was removed + assert_nil account.reload.overridden_limits + assert_equal account.cards_count, account.billed_cards_count + end +end diff --git a/saas/test/controllers/admin/stats_controller_test.rb b/saas/test/controllers/admin/stats_controller_test.rb index 9fa0c8b3f..d7de964c1 100644 --- a/saas/test/controllers/admin/stats_controller_test.rb +++ b/saas/test/controllers/admin/stats_controller_test.rb @@ -1,10 +1,6 @@ require "test_helper" class Admin::StatsControllerTest < ActionDispatch::IntegrationTest - def saas - Fizzy::Saas::Engine.routes.url_helpers - end - test "staff can access stats" do sign_in_as :david diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 086f60807..3969f18b4 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -15,35 +15,4 @@ class Account::BillingTest < ActiveSupport::TestCase account.subscription.update!(status: "active") assert_equal account.subscription, account.active_subscription end - - test "detect nearing card limit" do - # Paid plans are never limited - accounts(:"37s").update_column(:cards_count, 1_000_000) - assert_not accounts(:"37s").nearing_plan_cards_limit? - - # Free plan not near limit - accounts(:initech).update_column(:cards_count, 899) - assert_not accounts(:initech).nearing_plan_cards_limit? - - # Free plan near limit - accounts(:initech).update_column(:cards_count, 900) - assert_not accounts(:initech).nearing_plan_cards_limit? - - accounts(:initech).update_column(:cards_count, 901) - assert accounts(:initech).nearing_plan_cards_limit? - end - - test "detect exceeding card limit" do - # Paid plans are never limited - accounts(:"37s").update_column(:cards_count, 1_000_000) - assert_not accounts(:"37s").exceeding_card_limit? - - # Free plan under limit - accounts(:initech).update_column(:cards_count, 999) - assert_not accounts(:initech).exceeding_card_limit? - - # Free plan over limit - accounts(:initech).update_column(:cards_count, 1001) - assert accounts(:initech).exceeding_card_limit? - end end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb new file mode 100644 index 000000000..ad982203c --- /dev/null +++ b/saas/test/models/account/limited_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class Account::LimitedTest < ActiveSupport::TestCase + test "detect nearing card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").nearing_plan_cards_limit? + + # Free plan not near limit + accounts(:initech).update_column(:cards_count, 899) + assert_not accounts(:initech).nearing_plan_cards_limit? + + # Free plan near limit + accounts(:initech).update_column(:cards_count, 900) + assert_not accounts(:initech).nearing_plan_cards_limit? + + accounts(:initech).update_column(:cards_count, 901) + assert accounts(:initech).nearing_plan_cards_limit? + end + + test "detect exceeding card limit" do + # Paid plans are never limited + accounts(:"37s").update_column(:cards_count, 1_000_000) + assert_not accounts(:"37s").exceeding_card_limit? + + # Free plan under limit + accounts(:initech).update_column(:cards_count, 999) + assert_not accounts(:initech).exceeding_card_limit? + + # Free plan over limit + accounts(:initech).update_column(:cards_count, 1001) + assert accounts(:initech).exceeding_card_limit? + end + + test "override limits" do + account = accounts(:initech) + account.update_column(:cards_count, 1001) + + assert account.exceeding_card_limit? + assert_equal 1001, account.billed_cards_count + + account.override_limits card_count: 500 + assert_not account.exceeding_card_limit? + assert_equal 500, account.billed_cards_count + assert_equal 1001, account.cards_count # original unchanged + + account.reset_overridden_limits + assert account.exceeding_card_limit? + assert_equal 1001, account.billed_cards_count + end +end From 8dd31e363587d117d0fe26f0f4277dec95c2a3d0 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 16:44:53 +0100 Subject: [PATCH 23/36] Add system to comp accounts --- .../admin/billing_waivers_controller.rb | 13 ++++++++ saas/app/models/account/billing.rb | 25 ++++++++++++++- saas/app/models/account/billing_waiver.rb | 7 ++++ saas/app/models/account/subscription.rb | 2 +- saas/app/models/plan.rb | 4 +-- .../settings/_subscription_panel.html.erb | 2 +- saas/app/views/admin/accounts/edit.html.erb | 6 ++++ saas/config/routes.rb | 1 + ...15160000_create_account_billing_waivers.rb | 9 ++++++ saas/db/saas_schema.rb | 9 +++++- .../admin/billing_waivers_controller_test.rb | 32 +++++++++++++++++++ saas/test/models/account/billing_test.rb | 15 ++++++++- saas/test/models/account/limited_test.rb | 25 +++++++++++++++ 13 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 saas/app/controllers/admin/billing_waivers_controller.rb create mode 100644 saas/app/models/account/billing_waiver.rb create mode 100644 saas/db/migrate/20251215160000_create_account_billing_waivers.rb create mode 100644 saas/test/controllers/admin/billing_waivers_controller_test.rb diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb new file mode 100644 index 000000000..8895ad399 --- /dev/null +++ b/saas/app/controllers/admin/billing_waivers_controller.rb @@ -0,0 +1,13 @@ +class Admin::BillingWaiversController < AdminController + include Admin::AccountScoped + + def create + @account.comp + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account comped" + end + + def destroy + @account.uncomp + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped" + end +end \ No newline at end of file diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 2ef42edb7..5b52dd836 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -3,6 +3,7 @@ module Account::Billing included do has_one :subscription, class_name: "Account::Subscription", dependent: :destroy + has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy end def plan @@ -10,10 +11,32 @@ module Account::Billing end def active_subscription - subscription if subscription&.active? + if comped? + comped_subscription + else + subscription if subscription&.active? + end end def subscribed? subscription.present? end + + def comped? + billing_waiver.present? + end + + def comp + create_billing_waiver unless billing_waiver + end + + def uncomp + billing_waiver&.destroy + reload_billing_waiver + end + + private + def comped_subscription + @comped_subscription ||= billing_waiver&.subscription + end end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb new file mode 100644 index 000000000..c1968522a --- /dev/null +++ b/saas/app/models/account/billing_waiver.rb @@ -0,0 +1,7 @@ +class Account::BillingWaiver < SaasRecord + belongs_to :account + + def subscription + @subscription ||= Account::Subscription.new(plan_key: Plan.paid.key) + end +end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index cc4989343..937d11bcc 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -8,7 +8,7 @@ class Account::Subscription < SaasRecord delegate :paid?, to: :plan def plan - Plan.find(plan_key) + @plan ||= Plan.find(plan_key) end def to_be_canceled? diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 77a2cfbc7..835e94472 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -12,11 +12,11 @@ class Plan end def free - @free ||= all.find(&:free?) + @free ||= find(:free_v1) end def paid - @paid ||= all.find(&:paid?) + @paid ||= find(:monthly_v1) end def find(key) diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 52479f0b1..55fc2f63f 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -1,4 +1,4 @@ -<% if Current.user.admin? %> +<% if Current.user.admin? && !Current.account.comped? %>

Subscription

diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 1a2f90515..49c99773d 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -28,6 +28,12 @@ <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> <% end %> + <% if @account.comped? %> + <%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> + <% else %> + <%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--positive" %> + <% end %> + <%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %> diff --git a/saas/config/routes.rb b/saas/config/routes.rb index 7f9d36465..ece9fef17 100644 --- a/saas/config/routes.rb +++ b/saas/config/routes.rb @@ -7,6 +7,7 @@ Fizzy::Saas::Engine.routes.draw do resource :account_search, only: :create resources :accounts do resource :overridden_limits, only: :destroy + resource :billing_waiver, only: [ :create, :destroy ] end end end diff --git a/saas/db/migrate/20251215160000_create_account_billing_waivers.rb b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb new file mode 100644 index 000000000..2cc9023d1 --- /dev/null +++ b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb @@ -0,0 +1,9 @@ +class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2] + def change + create_table :account_billing_waivers, id: :uuid do |t| + t.references :account, null: false, type: :uuid, index: { unique: true } + + t.timestamps + end + end +end \ No newline at end of file diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index edfead6ad..15dea28fc 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_15_140000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_15_160000) do + create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_billing_waivers_on_account_id", unique: true + end + create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.integer "card_count" diff --git a/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb new file mode 100644 index 000000000..ad2f76721 --- /dev/null +++ b/saas/test/controllers/admin/billing_waivers_controller_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest + test "staff can comp an account" do + sign_in_as :david + account = accounts(:"37s") + + assert_not account.comped? + + untenanted do + post saas.admin_account_billing_waiver_path(account.external_account_id) + assert_redirected_to saas.edit_admin_account_path(account.external_account_id) + end + + assert account.reload.comped? + end + + test "staff can uncomp an account" do + sign_in_as :david + account = accounts(:"37s") + account.comp + + assert account.comped? + + untenanted do + delete saas.admin_account_billing_waiver_path(account.external_account_id) + assert_redirected_to saas.edit_admin_account_path(account.external_account_id) + end + + assert_not account.reload.comped? + end +end \ No newline at end of file diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 3969f18b4..cdb7c3fa9 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -2,7 +2,7 @@ require "test_helper" class Account::BillingTest < ActiveSupport::TestCase test "active subscription" do - account = Account.create!(name: "Test") + account = accounts(:initech) # No subscription assert_nil account.active_subscription @@ -15,4 +15,17 @@ class Account::BillingTest < ActiveSupport::TestCase account.subscription.update!(status: "active") assert_equal account.subscription, account.active_subscription end + + test "comped account" do + account = accounts(:"37s") + + assert_not account.comped? + + account.comp + assert account.comped? + + # Calling comp again does not create duplicate + account.comp + assert_equal 1, Account::BillingWaiver.where(account: account).count + end end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index ad982203c..335d6a37c 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -48,4 +48,29 @@ class Account::LimitedTest < ActiveSupport::TestCase assert account.exceeding_card_limit? assert_equal 1001, account.billed_cards_count end + + test "comped accounts are never limited" do + account = accounts(:initech) + account.update_column(:cards_count, 1_000_000) + + assert account.exceeding_card_limit? + assert account.nearing_plan_cards_limit? + + account.comp + + assert_not account.exceeding_card_limit? + assert_not account.nearing_plan_cards_limit? + end + + test "uncomping an account restores limits" do + account = accounts(:initech) + account.update_column(:cards_count, 1_000_000) + account.comp + + assert_not account.exceeding_card_limit? + + account.uncomp + + assert account.exceeding_card_limit? + end end From 3395b8d6ed2a6637e289af3cf0c6fd945ad161dd Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 18:26:42 +0100 Subject: [PATCH 24/36] Grab and show the next amount to pay from Stripe --- saas/app/controllers/stripe/webhooks_controller.rb | 12 +++++++++++- saas/app/helpers/subscriptions_helper.rb | 2 +- saas/app/models/account/subscription.rb | 4 ++++ ...t_amount_due_in_cents_to_account_subscriptions.rb | 5 +++++ saas/db/saas_schema.rb | 3 ++- .../controllers/stripe/webhooks_controller_test.rb | 9 +++++++-- 6 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 14c50c74d..22d8d4e0c 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -48,7 +48,8 @@ class Stripe::WebhooksController < ApplicationController stripe_subscription_id: stripe_subscription.id, status: stripe_subscription.status, current_period_end: current_period_end_for(stripe_subscription), - cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil + cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil, + next_amount_due_in_cents: next_amount_due_for(stripe_subscription) } yield subscription_properties if block_given? @@ -66,4 +67,13 @@ class Stripe::WebhooksController < ApplicationController timestamp = stripe_subscription.items.data.first&.current_period_end Time.at(timestamp) if timestamp end + + def next_amount_due_for(stripe_subscription) + return nil if stripe_subscription.status == "canceled" + + preview = Stripe::Invoice.create_preview(customer: stripe_subscription.customer, subscription: stripe_subscription.id) + preview.amount_due + rescue Stripe::InvalidRequestError + nil + end end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 32dd45402..2c05b68eb 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -5,7 +5,7 @@ module SubscriptionsHelper elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment of $#{ subscription.plan.price } will be billed on".html_safe + "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe end end end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 937d11bcc..0eac71b02 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -14,4 +14,8 @@ class Account::Subscription < SaasRecord def to_be_canceled? active? && cancel_at.present? end + + def next_amount_due + next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price + end end diff --git a/saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb b/saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb new file mode 100644 index 000000000..28838b314 --- /dev/null +++ b/saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb @@ -0,0 +1,5 @@ +class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2] + def change + add_column :account_subscriptions, :next_amount_due_in_cents, :integer + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 15dea28fc..59c78f4cb 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_160000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false @@ -31,6 +31,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_160000) do t.datetime "cancel_at" t.datetime "created_at", null: false t.datetime "current_period_end" + t.integer "next_amount_due_in_cents" t.string "plan_key" t.string "status" t.string "stripe_customer_id", null: false diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb index c1c8ac4d0..92f6b7508 100644 --- a/saas/test/controllers/stripe/webhooks_controller_test.rb +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -29,6 +29,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest Stripe::Webhook.stubs(:construct_event).returns(event) Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) + Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999)) post stripe_webhooks_path @@ -38,7 +39,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest assert_equal "active", @subscription.status end - test "subscription updated changes status" do + test "subscription updated changes status and syncs next amount due" do @subscription.update!(stripe_subscription_id: "sub_123", status: "active") stripe_sub = OpenStruct.new( @@ -53,11 +54,14 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest Stripe::Webhook.stubs(:construct_event).returns(event) Stripe::Subscription.stubs(:retrieve).returns(stripe_sub) + Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999)) post stripe_webhooks_path assert_response :ok - assert_equal "past_due", @subscription.reload.status + @subscription.reload + assert_equal "past_due", @subscription.status + assert_equal 1999, @subscription.next_amount_due_in_cents end test "subscription deleted cancels subscription" do @@ -82,6 +86,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest @subscription.reload assert_equal "canceled", @subscription.status assert_nil @subscription.stripe_subscription_id + assert_nil @subscription.next_amount_due_in_cents end private From f68aa0d94792231c3619bd2669f997383e5bbaf8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 21:49:23 +0100 Subject: [PATCH 25/36] Enable tax id collection --- saas/app/controllers/account/subscriptions_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 1e4c6d87e..7abe1dec1 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -14,6 +14,7 @@ class Account::SubscriptionsController < ApplicationController cancel_url: account_subscription_url, metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }, automatic_tax: { enabled: true }, + tax_id_collection: { enabled: true }, billing_address_collection: "required", customer_update: { address: "auto" } From c0050415f54286da46babf92be073efab61cfe49 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Mon, 15 Dec 2025 21:59:37 +0100 Subject: [PATCH 26/36] This is needed for vat id collection --- saas/app/controllers/account/subscriptions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 7abe1dec1..129de9c71 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -16,7 +16,7 @@ class Account::SubscriptionsController < ApplicationController automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: "required", - customer_update: { address: "auto" } + customer_update: { address: "auto", name: "auto" } redirect_to session.url, allow_other_host: true end From 973b4e4aed63fbef1cdeba2aa5bea4d6c7a7843d Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 08:50:54 +0100 Subject: [PATCH 27/36] Rename param to remove redundant bit Co-authored-by: Matt Almeida --- saas/app/controllers/stripe/webhooks_controller.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 22d8d4e0c..a111a1931 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -43,7 +43,7 @@ class Stripe::WebhooksController < ApplicationController def sync_subscription(stripe_subscription_id) stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id) - if subscription = find_subscription_by_customer(stripe_subscription.customer) + if subscription = find_subscription_by_stripe_customer(stripe_subscription.customer) subscription_properties = { stripe_subscription_id: stripe_subscription.id, status: stripe_subscription.status, @@ -59,8 +59,8 @@ class Stripe::WebhooksController < ApplicationController end end - def find_subscription_by_customer(customer_id) - Account::Subscription.find_by(stripe_customer_id: customer_id) + def find_subscription_by_stripe_customer(id) + Account::Subscription.find_by(stripe_customer_id: id) end def current_period_end_for(stripe_subscription) From 20b5e57dd35baaefa91be4dfae1ee5b608b191d5 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 09:40:33 +0100 Subject: [PATCH 28/36] Prefer if/else over early return --- saas/app/controllers/stripe/webhooks_controller.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index a111a1931..bf00fc23f 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -4,12 +4,12 @@ class Stripe::WebhooksController < ApplicationController skip_before_action :verify_authenticity_token def create - event = verify_webhook_signature - return head :bad_request unless event - - dispatch_stripe_event(event) - - head :ok + if event = verify_webhook_signature + dispatch_stripe_event(event) + head :ok + else + head :bad_request + end end private From e47657759fcf60ac16dc8f10792e302b8b2a8a12 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:08:25 +0100 Subject: [PATCH 29/36] More concise --- saas/app/models/account/limited.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index 6fed72e9e..eaf391ca1 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -8,11 +8,7 @@ module Account::Limited NEAR_CARD_LIMIT_THRESHOLD = 100 def override_limits(card_count:) - if overridden_limits - overridden_limits.update(card_count: card_count) - else - create_overridden_limits(card_count: card_count) - end + (overridden_limits || build_overridden_limits).update!(card_count:) end def billed_cards_count From b39a9ecab40afb3d2db80d481763f9fbd13f16a3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:22:52 +0100 Subject: [PATCH 30/36] Make active_subscription a private method --- saas/app/models/account/billing.rb | 16 ++++++++-------- saas/test/models/account/billing_test.rb | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 5b52dd836..7d51b11a9 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -10,14 +10,6 @@ module Account::Billing active_subscription&.plan || Plan.free end - def active_subscription - if comped? - comped_subscription - else - subscription if subscription&.active? - end - end - def subscribed? subscription.present? end @@ -36,6 +28,14 @@ module Account::Billing end private + def active_subscription + if comped? + comped_subscription + else + subscription if subscription&.active? + end + end + def comped_subscription @comped_subscription ||= billing_waiver&.subscription end diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index cdb7c3fa9..7283f47da 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -1,19 +1,19 @@ require "test_helper" class Account::BillingTest < ActiveSupport::TestCase - test "active subscription" do + test "plan reflects active subscription" do account = accounts(:initech) # No subscription - assert_nil account.active_subscription + assert_equal Plan.free, account.plan # Subscription but it is not active account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test") - assert_nil account.active_subscription + assert_equal Plan.free, account.plan # Active subscription exists account.subscription.update!(status: "active") - assert_equal account.subscription, account.active_subscription + assert_equal Plan.paid, account.plan end test "comped account" do From 71369de38df2b2ce393815f4a26fe5d4d05c48d7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:31:25 +0100 Subject: [PATCH 31/36] Extract method --- saas/app/models/account/limited.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index eaf391ca1..f7eb3cbc1 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -16,7 +16,7 @@ module Account::Limited end def nearing_plan_cards_limit? - plan.limit_cards? && (plan.card_limit - billed_cards_count) < NEAR_CARD_LIMIT_THRESHOLD + plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD end def exceeding_card_limit? @@ -27,4 +27,9 @@ module Account::Limited overridden_limits&.destroy reload_overridden_limits end + + private + def remaining_cards_count + plan.card_limit - billed_cards_count + end end From 8d11c62b7c69bbb585de1e632182a9cebbe71535 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:34:03 +0100 Subject: [PATCH 32/36] Rename to stripe_session to avoid confusions --- saas/app/controllers/account/subscriptions_controller.rb | 2 +- saas/app/views/account/subscriptions/show.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 129de9c71..8ddfb4b7a 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -23,7 +23,7 @@ class Account::SubscriptionsController < ApplicationController private def set_stripe_session - @session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] + @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] end def find_or_create_stripe_customer diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb index 64987b5d0..8d29a379e 100644 --- a/saas/app/views/account/subscriptions/show.html.erb +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -1,6 +1,6 @@ <% @page_title = "Thank you" %> -<% if @session&.payment_status == "paid" %> +<% if @stripe_session&.payment_status == "paid" %>

Thanks for buying Fizzy!

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

From bf045b99070f4cddc69ed675af2969c8e9164560 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:37:23 +0100 Subject: [PATCH 33/36] Format --- .../db/migrate/20251215160000_create_account_billing_waivers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/db/migrate/20251215160000_create_account_billing_waivers.rb b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb index 2cc9023d1..8f3732c09 100644 --- a/saas/db/migrate/20251215160000_create_account_billing_waivers.rb +++ b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb @@ -6,4 +6,4 @@ class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2] t.timestamps end end -end \ No newline at end of file +end From 2671e52821f988994679096e4cbfe11b4e010bca Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 10:44:19 +0100 Subject: [PATCH 34/36] Move presentation method to a regular view helper --- saas/app/helpers/subscriptions_helper.rb | 4 ++++ saas/app/models/plan.rb | 4 ---- .../app/views/account/settings/_subscription_panel.html.erb | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 2c05b68eb..a1c4c07d9 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -1,4 +1,8 @@ module SubscriptionsHelper + def plan_storage_limit(plan) + number_to_human_size(plan.storage_limit).delete(" ") + end + def subscription_period_end_action(subscription) if subscription.to_be_canceled? "Your Fizzy subscription ends on" diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 835e94472..7ef7f922e 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -47,8 +47,4 @@ class Plan def limit_cards? card_limit != Float::INFINITY end - - def formatted_storage_limit - ActionController::Base.helpers.number_to_human_size(storage_limit).delete(" ") - end end diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 55fc2f63f..44949e022 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -9,18 +9,18 @@

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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.

+

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.

- + <% else %>

Thank you for buying Fizzy

<% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - + <% end %> <% end %> From 5b2fa6b74a563863ecf8b8a65bba9a00a6c8bc18 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 11:11:49 +0100 Subject: [PATCH 35/36] Remove nested if --- saas/app/models/account/billing.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index 7d51b11a9..dada43721 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -31,8 +31,8 @@ module Account::Billing def active_subscription if comped? comped_subscription - else - subscription if subscription&.active? + elsif subscription&.active? + subscription end end From 3c197debd205897f3229865600b63a7a385b76e3 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 16 Dec 2025 11:14:17 +0100 Subject: [PATCH 36/36] Format --- saas/app/controllers/admin/billing_waivers_controller.rb | 2 +- saas/exe/stripe-dev | 4 ++-- .../controllers/accounts/subscriptions_controller_test.rb | 1 - .../test/controllers/admin/billing_waivers_controller_test.rb | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb index 8895ad399..713891069 100644 --- a/saas/app/controllers/admin/billing_waivers_controller.rb +++ b/saas/app/controllers/admin/billing_waivers_controller.rb @@ -10,4 +10,4 @@ class Admin::BillingWaiversController < AdminController @account.uncomp redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped" end -end \ No newline at end of file +end diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index c2aea61a4..2033385ff 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -44,8 +44,8 @@ File.write(LOG_FILE, "") # Start stripe listen in background pid = spawn( "stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks", - out: [LOG_FILE, "a"], - err: [LOG_FILE, "a"] + out: [ LOG_FILE, "a" ], + err: [ LOG_FILE, "a" ] ) Process.detach(pid) diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb index 9cf5d86bc..d98ac1d91 100644 --- a/saas/test/controllers/accounts/subscriptions_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -43,5 +43,4 @@ class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest post account_subscription_path assert_response :forbidden end - end diff --git a/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb index ad2f76721..8f7d14199 100644 --- a/saas/test/controllers/admin/billing_waivers_controller_test.rb +++ b/saas/test/controllers/admin/billing_waivers_controller_test.rb @@ -29,4 +29,4 @@ class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest assert_not account.reload.comped? end -end \ No newline at end of file +end