diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb
new file mode 100644
index 000000000..73bcdeada
--- /dev/null
+++ b/gems/fizzy-saas/app/controllers/signups_controller.rb
@@ -0,0 +1,31 @@
+class SignupsController < ApplicationController
+ require_untenanted_access
+
+ rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes,
+ with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
+ rate_limit only: :create, name: "long-term", to: 10, within: 30.minutes,
+ with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
+
+ http_basic_authenticate_with \
+ name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
+ password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
+
+ def new
+ @signup = Signup.new
+ end
+
+ def create
+ @signup = Signup.new(signup_params)
+
+ if @signup.process
+ redirect_to root_url(script_name: @signup.account.slug)
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+ def signup_params
+ params.require(:signup).permit(*Signup::PERMITTED_KEYS)
+ end
+end
diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb
new file mode 100644
index 000000000..c0114436a
--- /dev/null
+++ b/gems/fizzy-saas/app/models/signup.rb
@@ -0,0 +1,121 @@
+class Signup
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+ include ActiveModel::Validations
+
+ PERMITTED_KEYS = %i[ full_name email_address password company_name ]
+
+ # Input attributes
+ attr_accessor :company_name, :full_name, :email_address, :password
+ validates_presence_of :company_name, :full_name, :email_address, :password
+
+ # Output attributes
+ attr_reader :tenant, :account, :user, :queenbee_account
+
+ def initialize(...)
+ @company_name = nil
+ @full_name = nil
+ @email_address = nil
+ @password = nil
+ @tenant = nil
+ @account = nil
+ @user = nil
+ @queenbee_account = nil
+
+ super
+ end
+
+ def process
+ return false unless valid?
+
+ create_queenbee_account
+ create_tenant
+
+ true
+ rescue => error
+ destroy_tenant
+ destroy_queenbee_account
+
+ errors.add(:base, "An error occurred during signup: #{error.message}")
+
+ false
+ end
+
+ private
+ def create_queenbee_account
+ @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
+ end
+
+ def destroy_queenbee_account
+ @queenbee_account&.cancel
+ @queenbee_account = nil
+ end
+
+ def create_tenant
+ @tenant = queenbee_account.id.to_s
+ ApplicationRecord.create_tenant(tenant) do
+ @account = Account.create_with_admin_user(
+ account: {
+ external_account_id: tenant,
+ name: company_name
+ },
+ owner: {
+ name: full_name,
+ email_address: email_address,
+ password: password
+ }
+ )
+ @user = User.first
+ @account.setup_basic_template
+ end
+ end
+
+ def destroy_tenant
+ if tenant.present? && ApplicationRecord.tenant_exist?(tenant)
+ ApplicationRecord.destroy_tenant(tenant)
+ end
+ @user = nil
+ @account = nil
+ @tenant = nil
+ end
+
+ def queenbee_account_attributes
+ {}.tap do |attributes|
+ # Tell Queenbee to skip the request to create a local account. We've created it ourselves.
+ attributes[:skip_remote] = true
+
+ # # TODO: once we are doing our own email validation, consider setting this
+ # # Queenbee should not do spam checks on this account, we've done our own.
+ # attributes[:auto_allow] = true
+
+ # # TODO: Terms of Service
+ # attributes[:terms_of_service] = true
+
+ attributes[:product_name] = "fizzy"
+ attributes[:name] = company_name
+ attributes[:owner_name] = full_name
+ attributes[:owner_email] = email_address
+
+ attributes[:trial] = true
+ attributes[:subscription] = subscription_attributes
+ attributes[:remote_request] = request_attributes
+ end
+ end
+
+ def subscription_attributes
+ subscription = FreeV1Subscription
+
+ {}.tap do |attributes|
+ attributes[:name] = subscription.to_param
+ attributes[:price] = subscription.price
+ end
+ end
+
+ def request_attributes
+ {}.tap do |attributes|
+ attributes[:remote_address] = Current.ip_address
+ attributes[:user_agent] = Current.user_agent
+ attributes[:referrer] = Current.referrer
+ end
+ end
+end
diff --git a/gems/fizzy-saas/app/views/signups/new.html.erb b/gems/fizzy-saas/app/views/signups/new.html.erb
new file mode 100644
index 000000000..c357d3c0f
--- /dev/null
+++ b/gems/fizzy-saas/app/views/signups/new.html.erb
@@ -0,0 +1,60 @@
+<% @page_title = "Sign up for Fizzy" %>
+
+
" style="--panel-size: 65ch;">
+
Fizzy
+
Create your account
+
+ <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% if @signup.errors.any? %>
+
+
+ <% @signup.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+
+
diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb
index cb130af4f..698095350 100644
--- a/gems/fizzy-saas/config/routes.rb
+++ b/gems/fizzy-saas/config/routes.rb
@@ -1,3 +1,4 @@
Fizzy::Saas::Engine.routes.draw do
+ resource :signup, only: [ :new, :create ]
Queenbee.routes(self)
end
diff --git a/gems/fizzy-saas/test/controllers/signups_controller_test.rb b/gems/fizzy-saas/test/controllers/signups_controller_test.rb
new file mode 100644
index 000000000..8cbe8ec9f
--- /dev/null
+++ b/gems/fizzy-saas/test/controllers/signups_controller_test.rb
@@ -0,0 +1,87 @@
+require "test_helper"
+
+class SignupsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @signup_params = {
+ full_name: "Brian Wilson",
+ email_address: "brian@example.com",
+ company_name: "Beach Boys",
+ password: SecureRandom.hex(16)
+ }
+ @starting_tenants = ApplicationRecord.tenants
+
+ # Clear script_name for untenanted signup tests
+ integration_session.default_url_options[:script_name] = nil
+ end
+
+ test "should require http basic authentication" do
+ get saas.new_signup_url
+
+ assert_response :unauthorized
+ end
+
+ test "should get new" do
+ get saas.new_signup_url, headers: http_basic_auth_headers
+
+ assert_response :success
+ assert_select "h2", "Create your account"
+ assert_select "input[name='signup[full_name]']"
+ assert_select "input[name='signup[email_address]']"
+ assert_select "input[name='signup[company_name]']"
+ assert_select "input[name='signup[password]']"
+ end
+
+ test "should create signup and redirect to tenant root on success" do
+ Account.any_instance.expects(:setup_basic_template).once
+
+ assert_difference -> { ApplicationRecord.tenants.count }, 1 do
+ post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers
+ end
+
+ assert_response :redirect
+
+ new_tenant = (ApplicationRecord.tenants - @starting_tenants).first
+ ApplicationRecord.with_tenant(new_tenant) do
+ account = Account.sole
+ assert account, "Account should have been created"
+ assert_equal @signup_params[:company_name], account.name
+
+ user = User.find_by(email_address: @signup_params[:email_address])
+ assert user, "User should have been created"
+ assert_equal @signup_params[:full_name], user.name
+ assert_equal @signup_params[:email_address], user.email_address
+
+ assert_redirected_to root_url(script_name: account.slug)
+ end
+ end
+
+ test "should render new with errors when signup fails validation" do
+ invalid_params = @signup_params.merge(password: "")
+
+ assert_no_difference -> { ApplicationRecord.tenants.count } do
+ post saas.signup_url, params: { signup: invalid_params }, headers: http_basic_auth_headers
+ end
+
+ assert_response :unprocessable_entity
+ assert_select ".alert--error"
+ assert_select ".alert--error li", /Password can't be blank/
+ end
+
+ test "should render new with errors when signup processing fails" do
+ Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
+
+ assert_no_difference -> { ApplicationRecord.tenants.count } do
+ post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers
+ end
+
+ assert_response :unprocessable_entity
+ assert_select ".alert--error"
+ assert_select ".alert--error li", /An error occurred during signup/
+ end
+
+ private
+ def http_basic_auth_headers
+ credentials = ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword")
+ { "HTTP_AUTHORIZATION" => credentials }
+ end
+end
diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb
new file mode 100644
index 000000000..9566fc0b4
--- /dev/null
+++ b/gems/fizzy-saas/test/models/signup_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+
+class SignupTest < ActiveSupport::TestCase
+ setup do
+ @starting_tenants = ApplicationRecord.tenants
+ @signup = Signup.new(
+ email_address: "brian@example.com",
+ full_name: "Brian Wilson",
+ company_name: "Beach Boys",
+ password: SecureRandom.hex(16)
+ )
+ end
+
+ test "#process creates all the necessary objects for a new Fizzy account" do
+ Account.any_instance.expects(:setup_basic_template).once
+
+ assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ")
+ assert_empty @signup.errors
+
+ assert @signup.tenant
+ assert_includes ApplicationRecord.tenants, @signup.tenant
+
+ assert @signup.account
+ assert @signup.account.persisted?
+ assert @signup.account.external_account_id
+ assert_equal @signup.company_name, @signup.account.name
+ assert_equal @signup.tenant, @signup.account.external_account_id.to_s
+ assert_equal @signup.tenant, @signup.account.tenant
+
+ assert @signup.user
+ assert @signup.user.persisted?
+ assert_equal @signup.full_name, @signup.user.name
+ assert_equal @signup.email_address, @signup.user.email_address
+
+ auth_params = { email_address: @signup.email_address, password: @signup.password }
+ user = ApplicationRecord.with_tenant(@signup.tenant) { User.authenticate_by(**auth_params) }
+
+ assert user, "User should be able to authenticate with #{auth_params.inspect}"
+ assert_equal @signup.user, user
+ assert_equal @signup.tenant, @signup.user.tenant
+ end
+
+ test "#process does nothing if a basic validation error occurs" do
+ @signup.password = ""
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:password]
+
+ assert_nil @signup.tenant
+ assert_nil @signup.account
+ assert_nil @signup.user
+ assert_equal @starting_tenants, ApplicationRecord.tenants
+ end
+
+ test "#process does nothing if an error occurs creating the queenbee record" do
+ Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:base]
+
+ assert_nil @signup.tenant
+ assert_nil @signup.account
+ assert_nil @signup.user
+ assert_equal @starting_tenants, ApplicationRecord.tenants
+ end
+
+ test "#process does nothing if an error occurs creating the tenant" do
+ ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists")
+
+ Queenbee::Remote::Account.any_instance.expects(:cancel).once
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:base]
+
+ assert_nil @signup.tenant
+ assert_nil @signup.account
+ assert_nil @signup.user
+ assert_equal @starting_tenants, ApplicationRecord.tenants
+ end
+
+ test "#process does nothing if an error occurs creating the tenanted records" do
+ Account.stubs(:create_with_admin_user).raises(ActiveRecord::RecordInvalid, "Validation failed: Name can't be blank")
+
+ Queenbee::Remote::Account.any_instance.expects(:cancel).once
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:base]
+
+ assert_nil @signup.tenant
+ assert_nil @signup.account
+ assert_nil @signup.user
+ assert_equal @starting_tenants, ApplicationRecord.tenants
+ end
+end