diff --git a/.rubocop.yml b/.rubocop.yml index 16773cd4c..c0d945b70 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,3 +11,5 @@ AllCops: Exclude: - 'db/migrate/**/*' - 'db/schema*.rb' + - 'saas/db/migrate/**/*' + - 'saas/db/saas_schema.rb' diff --git a/Gemfile.saas b/Gemfile.saas index 2366e80aa..05e6bbabd 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -6,7 +6,7 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" -gem "fizzy-saas", bc: "fizzy-saas" +gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index bd811d9d6..576bbc731 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -19,28 +19,6 @@ GIT rails (>= 7.0) rainbow -GIT - remote: https://github.com/basecamp/fizzy-saas - revision: 8eaa692316bb27ed73bd628b4735082585498ca0 - specs: - fizzy-saas (0.1.0) - audits1984 - console1984 - prometheus-client-mmap (~> 1.4.0) - queenbee - rails (>= 8.1.0.beta1) - rails_structured_logging - sentry-rails - sentry-ruby - yabeda - yabeda-actioncable - yabeda-activejob - yabeda-gc - yabeda-http_requests - yabeda-prometheus-mmap - yabeda-puma-plugin - yabeda-rails (>= 0.10) - GIT remote: https://github.com/basecamp/lexxy revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 @@ -181,6 +159,27 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +PATH + remote: saas + specs: + fizzy-saas (0.1.0) + audits1984 + console1984 + prometheus-client-mmap (~> 1.4.0) + queenbee + rails (>= 8.1.0.beta1) + rails_structured_logging + sentry-rails + sentry-ruby + yabeda + yabeda-actioncable + yabeda-activejob + yabeda-gc + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails (>= 0.10) + GEM remote: https://rubygems.org/ specs: diff --git a/bin/setup b/bin/setup index e1a9dabb8..a49eaa9d4 100755 --- a/bin/setup +++ b/bin/setup @@ -136,8 +136,7 @@ bundle config set --local auto_install true step "Installing RubyGems" bundle install if [ -n "$SAAS" ]; then - saas_setup=$(bundle show fizzy-saas)/bin/setup - source "$saas_setup" + source "$app_root/saas/bin/setup" else if [ "$DATABASE_ADAPTER" = "mysql" ]; then oss_mysql_setup diff --git a/config/database.yml b/config/database.yml index d5ab7dbc0..359ac6655 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,6 +1,6 @@ <% config_path = if Fizzy.saas? - gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir + gem_path = Rails.root.join("saas").to_s File.join(gem_path, "config", "database.yml") else File.join("config", "database.#{Fizzy.db_adapter}.yml") diff --git a/saas/.kamal/hooks/post-deploy b/saas/.kamal/hooks/post-deploy new file mode 100755 index 000000000..871506078 --- /dev/null +++ b/saas/.kamal/hooks/post-deploy @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +MESSAGE="$KAMAL_PERFORMER deployed $KAMAL_SERVICE_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +bin/notify_dash_of_deployment "$MESSAGE" $KAMAL_VERSION $KAMAL_PERFORMER $CURRENT_BRANCH $KAMAL_DESTINATION $KAMAL_RUNTIME + +if [[ $CURRENT_BRANCH == "main" && $KAMAL_DESTINATION == "production" ]]; then + gh release create $KAMAL_SERVICE_VERSION --target $KAMAL_VERSION --generate-notes 2> /dev/null || true + + RELEASE_URL=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .url) + RELEASE_BODY=$(gh release view $KAMAL_SERVICE_VERSION --json url,body --jq .body) + + bin/broadcast_to_bc "$MESSAGE "$'\n'"$RELEASE_URL "$'\n'"$RELEASE_BODY" +else + bin/broadcast_to_bc "$MESSAGE" +fi diff --git a/saas/.kamal/hooks/pre-connect b/saas/.kamal/hooks/pre-connect new file mode 100755 index 000000000..39c98c167 --- /dev/null +++ b/saas/.kamal/hooks/pre-connect @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# Validate hostnames are FQDNs ending in -int.37signals.com +if command -v yq >/dev/null 2>&1; then + declare -A SUGGESTIONS + while IFS= read -r host; do + if [[ ! $host =~ -int\.37signals\.com$ ]]; then + if [[ $host =~ -4[0-9]{2}$ ]]; then + SUGGESTIONS["$host"]="$host.df-ams-int.37signals.com" + elif [[ $host =~ -1[0-9]{2}$ ]]; then + SUGGESTIONS["$host"]="$host.df-iad-int.37signals.com" + else + SUGGESTIONS["$host"]="$host.sc-chi-int.37signals.com" + fi + fi + done < <(bin/kamal config -d "${KAMAL_DESTINATION:-production}" 2>/dev/null | yq -r '.":hosts"[]') + + if [ ${#SUGGESTIONS[@]} -gt 0 ]; then + echo "Unqualified hostnames found in config/deploy.${KAMAL_DESTINATION:-production}.yml:" >&2 + echo "" >&2 + echo "Update to use fully-qualified hostnames:" >&2 + for host in "${!SUGGESTIONS[@]}"; do + echo " $host → ${SUGGESTIONS[$host]}" >&2 + done + exit 1 + fi +fi + +# Verify Tailscale connection and SSH authentication before deploying. +tailscale_cmd() { + if command -v tailscale >/dev/null 2>&1; then + tailscale "$@" + elif [ -f "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]; then + env TAILSCALE_BE_CLI=1 /Applications/Tailscale.app/Contents/MacOS/Tailscale "$@" + else + return 1 + fi +} + +on_tailscale() { + tailscale_cmd status --json 2>/dev/null | jq -e '.Self.Online' >/dev/null 2>&1 +} + +# Check Tailscale connection +if ! on_tailscale; then + echo "" >&2 + echo "You must be connected to Tailscale to deploy." >&2 + echo "" >&2 + echo "→ Connect to Tailscale and try again" >&2 + echo "" >&2 + exit 1 +fi + +# Verify SSH access +echo "Deploying via Tailscale. Verifying SSH access…" >&2 + +TEST_HOST="fizzy-app-101.df-iad-int.37signals.com" + +SSH_OUTPUT=$(ssh -o ConnectTimeout=5 "app@$TEST_HOST" true 2>&1) +SSH_EXIT=$? + +echo "$SSH_OUTPUT" >&2 + +if echo "$SSH_OUTPUT" | grep -q "Permission denied"; then + GITHUB_USER=$(gh api user 2>/dev/null | jq -r '.login // "unknown"') + GITHUB_KEYS_URL="https://github.com/${GITHUB_USER}.keys" + + echo "" >&2 + echo "ERROR: SSH authentication failed" >&2 + echo "" >&2 + echo "You must deploy with an SSH key that's on your GitHub account." >&2 + echo "" >&2 + echo "→ Verify your public key is at $GITHUB_KEYS_URL" >&2 + echo " Add it at https://github.com/settings/keys if not" >&2 + echo "" >&2 + echo "Note that SSH keys are pulled from GitHub every 5 minutes, so if you've" >&2 + echo "just added a new key to GitHub, try again in five." >&2 + echo "" >&2 + exit 1 +fi + +exit $SSH_EXIT diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta new file mode 100644 index 000000000..0fb3db660 --- /dev/null +++ b/saas/.kamal/secrets.beta @@ -0,0 +1,26 @@ +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) +DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS) +ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) +QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) +SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) +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 new file mode 100644 index 000000000..672359a13 --- /dev/null +++ b/saas/.kamal/secrets.production @@ -0,0 +1,26 @@ +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) +DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS) +ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) +QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) +SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) +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 new file mode 100644 index 000000000..7121f5e3a --- /dev/null +++ b/saas/.kamal/secrets.staging @@ -0,0 +1,26 @@ +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) +DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS) +RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) +MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS) +MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS) +MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS) +MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS) +MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS) +MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS) +SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS) +VAPID_PUBLIC_KEY=$(kamal secrets extract VAPID_PUBLIC_KEY $SECRETS) +VAPID_PRIVATE_KEY=$(kamal secrets extract VAPID_PRIVATE_KEY $SECRETS) +ACTIVE_STORAGE_ACCESS_KEY_ID=$(kamal secrets extract ACTIVE_STORAGE_ACCESS_KEY_ID $SECRETS) +ACTIVE_STORAGE_SECRET_ACCESS_KEY=$(kamal secrets extract ACTIVE_STORAGE_SECRET_ACCESS_KEY $SECRETS) +QUEENBEE_API_TOKEN=$(kamal secrets extract QUEENBEE_API_TOKEN $SECRETS) +SIGNAL_ID_SECRET=$(kamal secrets extract SIGNAL_ID_SECRET $SECRETS) +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/Dockerfile b/saas/Dockerfile new file mode 100644 index 000000000..92fd9c4ec --- /dev/null +++ b/saas/Dockerfile @@ -0,0 +1,84 @@ +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.7 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + SAAS="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_GEMFILE="Gemfile.saas" \ + BUNDLE_WITHOUT="development" + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends -y build-essential pkg-config git libvips libyaml-dev libssl-dev && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock Gemfile.saas Gemfile.saas.lock .ruby-version ./ +COPY lib/fizzy.rb ./lib/fizzy.rb +COPY saas/fizzy-saas.gemspec ./saas/ +COPY saas/lib/fizzy/saas/version.rb ./saas/lib/fizzy/saas/ +RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-permabundle-${RUBY_VERSION},sharing=locked,target=/permabundle \ + gem install bundler && \ + BUNDLE_PATH=/permabundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ + cp -a /permabundle/. "$BUNDLE_PATH"/ && \ + bundle clean --force && \ + rm -rf "$BUNDLE_PATH"/ruby/*/bundler/gems/*/.git && \ + find "$BUNDLE_PATH" -type f \( -name '*.gem' -o -iname '*.a' -o -iname '*.o' -o -iname '*.h' -o -iname '*.c' -o -iname '*.hpp' -o -iname '*.cpp' \) -delete && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompiling assets for production +RUN SECRET_KEY_BASE_DUMMY=1 \ + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=1 \ + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=1 \ + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=1 \ + ./bin/rails assets:precompile + +# Final stage for app image +FROM base + +# Install packages needed for deployment +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools sqlite3 libjemalloc-dev && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Copy built artifacts: gems, application +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN useradd rails --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER rails:rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Ruby GC tuning values pulled from Autotuner recommendations +ENV RUBY_GC_HEAP_0_INIT_SLOTS=692636 \ + RUBY_GC_HEAP_1_INIT_SLOTS=175943 \ + RUBY_GC_HEAP_2_INIT_SLOTS=148807 \ + RUBY_GC_HEAP_3_INIT_SLOTS=9169 \ + RUBY_GC_HEAP_4_INIT_SLOTS=3054 \ + RUBY_GC_MALLOC_LIMIT=67108864 \ + RUBY_GC_MALLOC_LIMIT_MAX=134217728 \ + LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 + +# Start the server by default, this can be overwritten at runtime +EXPOSE 80 443 9394 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/saas/Gemfile b/saas/Gemfile new file mode 100644 index 000000000..bb3829719 --- /dev/null +++ b/saas/Gemfile @@ -0,0 +1,9 @@ +source "https://rubygems.org" +git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } + +# 37id and Queenbee integration +gem "queenbee", bc: "queenbee-plugin", ref: "14312a940471e20617b38cdec7c092a01567d18b" +gem "rails_structured_logging", bc: "rails-structured-logging" +gem "activeresource", require: "active_resource" # needed by queenbee + +gem "rubocop-rails-omakase", require: false diff --git a/saas/Gemfile.lock b/saas/Gemfile.lock new file mode 100644 index 000000000..16c647b27 --- /dev/null +++ b/saas/Gemfile.lock @@ -0,0 +1,291 @@ +GIT + remote: https://github.com/basecamp/queenbee-plugin + revision: 14312a940471e20617b38cdec7c092a01567d18b + ref: 14312a940471e20617b38cdec7c092a01567d18b + specs: + queenbee (3.2.0) + activeresource + builder + rexml + +GIT + remote: https://github.com/basecamp/rails-structured-logging + revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 + specs: + rails_structured_logging (0.2.1) + json + rails (>= 6.0.0) + +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.1) + activesupport (= 8.1.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.1) + activesupport (= 8.1.1) + globalid (>= 0.3.6) + activemodel (8.1.1) + activesupport (= 8.1.1) + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) + builder (~> 3.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) + timeout (>= 0.4.0) + activeresource (6.1.4) + activemodel (>= 6.0) + activemodel-serializers-xml (~> 1.0) + activesupport (>= 6.0) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) + marcel (~> 1.0) + activesupport (8.1.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (3.2.3) + builder (3.3.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crass (1.0.6) + date (3.5.0) + drb (2.2.3) + erb (6.0.0) + erubi (1.13.1) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.16.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + mini_mime (1.1.5) + minitest (5.25.5) + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + psych (5.2.6) + date + stringio + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) + bundler (>= 1.15.0) + railties (= 8.1.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.1) + rdoc (6.15.1) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.2) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + stringio (3.1.8) + thor (1.4.0) + timeout (0.4.4) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.0.3) + useragent (0.16.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + activeresource + queenbee! + rails_structured_logging! + rubocop-rails-omakase + +BUNDLED WITH + 2.7.0 diff --git a/saas/LICENSE.md b/saas/LICENSE.md new file mode 100644 index 000000000..db5383855 --- /dev/null +++ b/saas/LICENSE.md @@ -0,0 +1,10 @@ +# O'Saasy License Agreement + +Copyright © 2025, 37signals LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/saas/README.md b/saas/README.md new file mode 100644 index 000000000..80ff67a36 --- /dev/null +++ b/saas/README.md @@ -0,0 +1,79 @@ +This is a Rails engine that [37signals](https://37signals.com/) bundles with [Fizzy](https://github.com/basecamp/fizzy) to offer the hosted version at https://fizzy.do. + +## Development + +To make Fizzy run in SaaS mode, run this in the terminal: + +```ruby +bin/rails saas:enable +``` + +To go back to open source mode: + +```ruby +bin/rails saas:disable +``` + +Then you can work do [Fizzy development as usual](https://github.com/basecamp/fizzy). + +## How to update Fizzy + +After making changes to this gem, you need to update Fizzy to pick up the changes: + +```ruby +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. + +### 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. + +## Handbook + +See the [Fizzy handbook](https://handbooks.37signals.works/18/fizzy) for runbooks and more. + +### Production + +- https://app.fizzy.do/ + +This environment uses a FlashBlade bucket for blob storage. + +### Beta + +Beta is primarily intended for testing product features. It uses the same production database and Active Storage configuration. + +Beta tenant is: + +- https://fizzy-beta.37signals.com + +### Staging + +Staging is primarily intended for testing infrastructure changes. It uses production-like but separate database and Active Storage configurations. + +- https://app.fizzy-staging.com/ + +## License + +fizzy-saas is released under the [O'Saasy License](LICENSE.md). diff --git a/saas/Rakefile b/saas/Rakefile new file mode 100644 index 000000000..e7793b5c1 --- /dev/null +++ b/saas/Rakefile @@ -0,0 +1,8 @@ +require "bundler/setup" + +APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) +load "rails/tasks/engine.rake" + +load "rails/tasks/statistics.rake" + +require "bundler/gem_tasks" diff --git a/saas/app/assets/images/fizzy/saas/.keep b/saas/app/assets/images/fizzy/saas/.keep new file mode 100644 index 000000000..e69de29bb 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/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..8ddfb4b7a --- /dev/null +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -0,0 +1,42 @@ +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 }, + automatic_tax: { enabled: true }, + tax_id_collection: { enabled: true }, + billing_address_collection: "required", + customer_update: { address: "auto", name: "auto" } + + redirect_to session.url, allow_other_host: true + end + + private + def set_stripe_session + @stripe_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..ba6200d1b --- /dev/null +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -0,0 +1,27 @@ +class Admin::AccountsController < AdminController + include Admin::AccountScoped + + layout "public" + + before_action :set_account, only: %i[ edit update ] + + def index + end + + def edit + end + + def update + @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 + def set_account + @account = Account.find_by!(external_account_id: params[:id]) + end + + def overridden_card_count_param + params[:account][:overridden_card_count].to_i + end +end 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..713891069 --- /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 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/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/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/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_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb new file mode 100644 index 000000000..b92ab06f3 --- /dev/null +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -0,0 +1,12 @@ +module Card::LimitedPublishing + extend ActiveSupport::Concern + + included do + before_action :ensure_can_publish_cards, only: %i[ create ] + end + + private + def ensure_can_publish_cards + head :forbidden if Current.account.exceeding_card_limit? + end +end diff --git a/saas/app/controllers/saas_admin_controller.rb b/saas/app/controllers/saas_admin_controller.rb new file mode 100644 index 000000000..f68ca492e --- /dev/null +++ b/saas/app/controllers/saas_admin_controller.rb @@ -0,0 +1,6 @@ +class SaasAdminController < ::AdminController + private + def find_current_auditor + Current.identity + 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..bf00fc23f --- /dev/null +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -0,0 +1,79 @@ +class Stripe::WebhooksController < ApplicationController + allow_unauthenticated_access + skip_before_action :require_account + skip_before_action :verify_authenticity_token + + def create + if event = verify_webhook_signature + dispatch_stripe_event(event) + head :ok + else + head :bad_request + end + end + + private + def dispatch_stripe_event(event) + case event.type + 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 + + 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 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 + + # 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_stripe_customer(stripe_subscription.customer) + subscription_properties = { + 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, + next_amount_due_in_cents: next_amount_due_for(stripe_subscription) + } + + yield subscription_properties if block_given? + subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled" + + subscription.update!(subscription_properties) + end + end + + def find_subscription_by_stripe_customer(id) + Account::Subscription.find_by(stripe_customer_id: id) + end + + def current_period_end_for(stripe_subscription) + 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 new file mode 100644 index 000000000..a1c4c07d9 --- /dev/null +++ b/saas/app/helpers/subscriptions_helper.rb @@ -0,0 +1,15 @@ +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" + elsif subscription.canceled? + "Your Fizzy subscription ended on" + else + "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe + 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..dada43721 --- /dev/null +++ b/saas/app/models/account/billing.rb @@ -0,0 +1,42 @@ +module Account::Billing + extend ActiveSupport::Concern + + included do + has_one :subscription, class_name: "Account::Subscription", dependent: :destroy + has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy + end + + def plan + active_subscription&.plan || Plan.free + 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 active_subscription + if comped? + comped_subscription + elsif subscription&.active? + subscription + end + end + + 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/limited.rb b/saas/app/models/account/limited.rb new file mode 100644 index 000000000..f7eb3cbc1 --- /dev/null +++ b/saas/app/models/account/limited.rb @@ -0,0 +1,35 @@ +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:) + (overridden_limits || build_overridden_limits).update!(card_count:) + end + + def billed_cards_count + overridden_limits&.card_count || cards_count + end + + def nearing_plan_cards_limit? + plan.limit_cards? && remaining_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 + + private + def remaining_cards_count + plan.card_limit - billed_cards_count + 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/models/account/subscription.rb b/saas/app/models/account/subscription.rb new file mode 100644 index 000000000..0eac71b02 --- /dev/null +++ b/saas/app/models/account/subscription.rb @@ -0,0 +1,21 @@ +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 ||= Plan.find(plan_key) + end + + 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/app/models/plan.rb b/saas/app/models/plan.rb new file mode 100644 index 000000000..7ef7f922e --- /dev/null +++ b/saas/app/models/plan.rb @@ -0,0 +1,50 @@ +class Plan + PLANS = { + free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, + monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] } + } + + 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 ||= find(:free_v1) + end + + def paid + @paid ||= find(:monthly_v1) + 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 +end diff --git a/saas/app/models/saas_record.rb b/saas/app/models/saas_record.rb new file mode 100644 index 000000000..37a1d8980 --- /dev/null +++ b/saas/app/models/saas_record.rb @@ -0,0 +1,5 @@ +class SaasRecord < ActiveRecord::Base + self.abstract_class = true + + connects_to database: { writing: :saas, reading: :saas } +end diff --git a/saas/app/models/subscription.rb b/saas/app/models/subscription.rb new file mode 100644 index 000000000..5efb1a7fa --- /dev/null +++ b/saas/app/models/subscription.rb @@ -0,0 +1,13 @@ +class Subscription < Queenbee::Subscription + SHORT_NAMES = %w[ FreeV1 ] + + def self.short_name + name.demodulize + end + + class FreeV1 < Subscription + property :proper_name, "Free Subscription" + property :price, 0 + property :frequency, "yearly" + 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..44949e022 --- /dev/null +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -0,0 +1,28 @@ +<% if Current.user.admin? && !Current.account.comped? %> +
+

Subscription

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

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

+ <% else %> +

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

+ <% end %> + +

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

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

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

+ + <% 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..d1c06e7f0 --- /dev/null +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -0,0 +1,12 @@ +
+ <% 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/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb new file mode 100644 index 000000000..8d29a379e --- /dev/null +++ b/saas/app/views/account/subscriptions/show.html.erb @@ -0,0 +1,9 @@ +<% @page_title = "Thank you" %> + +<% if @stripe_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..49c99773d --- /dev/null +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -0,0 +1,39 @@ +
+
+

Edit Account <%= @account.external_account_id %>

+ +
+
+
Name:
+
<%= @account.name %>
+
+
+
Actual card count:
+
<%= @account.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" %> +
+
+ + + <% end %> + + <% 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 %> + + <% 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/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 +

+
+ + +
+ +
+
+

+ Top 20 Accounts by Card Count +

+
+ + +
+
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..e40aa9fb5 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -0,0 +1,9 @@ +<% if Current.account.nearing_plan_cards_limit? %> +
+ <% if Current.user.admin? %> + You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. + <% else %> + This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon + <% end %> +
+<% end %> diff --git a/saas/app/views/layouts/fizzy/saas/application.html.erb b/saas/app/views/layouts/fizzy/saas/application.html.erb new file mode 100644 index 000000000..144b37838 --- /dev/null +++ b/saas/app/views/layouts/fizzy/saas/application.html.erb @@ -0,0 +1,17 @@ + + + + Fizzy saas + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%= stylesheet_link_tag "fizzy/saas/application", media: "all" %> + + + +<%= yield %> + + + diff --git a/saas/app/views/signup/completions/new.html.erb b/saas/app/views/signup/completions/new.html.erb new file mode 100644 index 000000000..aee8f4586 --- /dev/null +++ b/saas/app/views/signup/completions/new.html.erb @@ -0,0 +1,30 @@ +<% @page_title = "Complete your sign-up" %> + +
"> +

<%= @page_title %>

+ + <%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %> + <%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %> + +

You’re one step away. Just enter your name to get your own Fizzy account.

+ + <% if @signup.errors.any? %> +
+ +
+ <% end %> + + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/saas/app/views/signup/new.html.erb b/saas/app/views/signup/new.html.erb new file mode 100644 index 000000000..93166dd2f --- /dev/null +++ b/saas/app/views/signup/new.html.erb @@ -0,0 +1,28 @@ +<% @page_title = "Sign up for Fizzy" %> + +
"> +

Sign up

+ + <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %> + <%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %> + + <% if @signup.errors.any? %> +
+ +
+ <% end %> + + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/saas/bin/rails b/saas/bin/rails new file mode 100755 index 000000000..42a0e5bce --- /dev/null +++ b/saas/bin/rails @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path("..", __dir__) +ENGINE_PATH = File.expand_path("../lib/fizzy/saas/engine", __dir__) +APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) + +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) + +require "rails/all" +require "rails/engine/commands" diff --git a/saas/bin/rubocop b/saas/bin/rubocop new file mode 100755 index 000000000..40330c0ff --- /dev/null +++ b/saas/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/saas/bin/setup b/saas/bin/setup new file mode 100755 index 000000000..bdebb3a1a --- /dev/null +++ b/saas/bin/setup @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -eo pipefail + +# SaaS-specific setup tasks +# This script is called from the main Fizzy bin/setup when SAAS is enabled + +if [ -e tmp/minio-dev.txt ]; then + step "Starting Docker services" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup minio" + step "Configuring MinIO" bin/minio-setup +fi + +step "Starting mysql" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup mysql80" diff --git a/saas/config/database.yml b/saas/config/database.yml new file mode 100644 index 000000000..41be3a0e2 --- /dev/null +++ b/saas/config/database.yml @@ -0,0 +1,125 @@ +<% + if ENV["MIGRATE"].present? + mysql_app_user_key = "MYSQL_ALTER_USER" + mysql_app_password_key = "MYSQL_ALTER_PASSWORD" + max_execution_time_ms = 0 # No limit + else + mysql_app_user_key = "MYSQL_APP_USER" + mysql_app_password_key = "MYSQL_APP_PASSWORD" + max_execution_time_ms = 5_000 + end + + mysql_app_user = ENV[mysql_app_user_key] + mysql_app_password = ENV[mysql_app_password_key] + + gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir +%> + +default: &default + adapter: trilogy + host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %> + port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %> + pool: 50 + timeout: 5000 + variables: + transaction_isolation: READ-COMMITTED + max_execution_time: <%= max_execution_time_ms %> + +development: + primary: + <<: *default + database: fizzy_development + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: + <<: *default + database: fizzy_development + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: true + cable: + <<: *default + database: development_cable + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/cable_migrate + cache: + <<: *default + database: development_cache + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/cache_migrate + queue: + <<: *default + database: development_queue + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: db/queue_migrate + saas: + <<: *default + database: fizzy_saas_development + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: <%= File.join(gem_path, "db", "migrate") %> + schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + primary: + <<: *default + database: fizzy_test + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: + <<: *default + database: fizzy_test + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + replica: true + saas: + <<: *default + database: fizzy_saas_test + port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> + migrations_paths: <%= File.join(gem_path, "db", "migrate") %> + schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> + +production: &production + primary: + <<: *default + database: fizzy_production + host: <%= ENV["MYSQL_DATABASE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + replica: + <<: *default + database: fizzy_production + host: <%= ENV["MYSQL_DATABASE_REPLICA_HOST"] %> + username: <%= ENV["MYSQL_READONLY_USER"] %> + password: <%= ENV["MYSQL_READONLY_PASSWORD"] %> + replica: true + cable: + <<: *default + database: fizzy_solidcable_production + host: <%= ENV["MYSQL_SOLID_CABLE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: db/cable_migrate + queue: + <<: *default + database: fizzy_solidqueue_production + host: <%= ENV["MYSQL_SOLID_QUEUE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: db/queue_migrate + cache: + <<: *default + database: fizzy_solidcache_production + host: <%= ENV["MYSQL_SOLID_CACHE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: db/cache_migrate + saas: + <<: *default + database: fizzy_saas_production + host: <%= ENV["MYSQL_DATABASE_HOST"] %> + username: <%= mysql_app_user %> + password: <%= mysql_app_password %> + migrations_paths: <%= File.join(gem_path, "db", "migrate") %> + schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> + +beta: *production +staging: *production diff --git a/saas/config/deploy.beta.yml b/saas/config/deploy.beta.yml new file mode 100644 index 000000000..4aefe5663 --- /dev/null +++ b/saas/config/deploy.beta.yml @@ -0,0 +1,68 @@ +retain_containers: 1 + +servers: + web: + hosts: + - fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi + - fizzy-beta-app-101.df-iad-int.37signals.com: df_iad + labels: + otel_scrape_enabled: true + +# we don't run the jobs role in beta +allow_empty_roles: true + +proxy: + ssl: false + +ssh: + user: app + +env: + clear: + RAILS_ENV: beta + MYSQL_DATABASE_HOST: fizzy-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary + MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101 + secret: + - RAILS_MASTER_KEY + - MYSQL_ALTER_PASSWORD + - MYSQL_ALTER_USER + - MYSQL_APP_PASSWORD + - MYSQL_APP_USER + - MYSQL_READONLY_PASSWORD + - MYSQL_READONLY_USER + - SECRET_KEY_BASE + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY + - ACTIVE_STORAGE_ACCESS_KEY_ID + - ACTIVE_STORAGE_SECRET_ACCESS_KEY + - QUEENBEE_API_TOKEN + - SIGNAL_ID_SECRET + - SENTRY_DSN + - 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: + PRIMARY_DATACENTER: true + +accessories: + load-balancer: + image: basecamp/kamal-proxy:lb + host: fizzy-beta-lb-01.sc-chi-int.37signals.com + labels: + otel_role: load-balancer + otel_service: fizzy-load-balancer + otel_scrape_enabled: true + options: + publish: + - 80:80 + - 443:443 + volumes: + - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/saas/config/deploy.production.yml b/saas/config/deploy.production.yml new file mode 100644 index 000000000..70556948e --- /dev/null +++ b/saas/config/deploy.production.yml @@ -0,0 +1,88 @@ +retain_containers: 2 + +servers: + web: + hosts: + - fizzy-app-01.sc-chi-int.37signals.com: sc_chi + - fizzy-app-02.sc-chi-int.37signals.com: sc_chi + - fizzy-app-101.df-iad-int.37signals.com: df_iad + - fizzy-app-102.df-iad-int.37signals.com: df_iad + - fizzy-app-401.df-ams-int.37signals.com: df_ams + - fizzy-app-402.df-ams-int.37signals.com: df_ams + labels: + otel_scrape_enabled: true + jobs: + hosts: + - fizzy-jobs-101.df-iad-int.37signals.com: df_iad + - fizzy-jobs-102.df-iad-int.37signals.com: df_iad + labels: + otel_scrape_enabled: true + +proxy: + ssl: false + +ssh: + user: app + +env: + clear: + RAILS_ENV: production + MYSQL_DATABASE_HOST: fizzy-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary + secret: + - RAILS_MASTER_KEY + - MYSQL_ALTER_PASSWORD + - MYSQL_ALTER_USER + - MYSQL_APP_PASSWORD + - MYSQL_APP_USER + - MYSQL_READONLY_PASSWORD + - MYSQL_READONLY_USER + - SECRET_KEY_BASE + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY + - ACTIVE_STORAGE_ACCESS_KEY_ID + - ACTIVE_STORAGE_SECRET_ACCESS_KEY + - QUEENBEE_API_TOKEN + - SIGNAL_ID_SECRET + - SENTRY_DSN + - 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 + df_iad: + MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-101.df-iad-int.37signals.com + PRIMARY_DATACENTER: true + df_ams: + MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-401.df-ams-int.37signals.com + + +accessories: + load-balancer: + image: basecamp/kamal-proxy:lb + hosts: + - fizzy-lb-101.df-iad-int.37signals.com + - fizzy-lb-102.df-iad-int.37signals.com + - fizzy-lb-01.sc-chi-int.37signals.com + - fizzy-lb-02.sc-chi-int.37signals.com + - fizzy-lb-401.df-ams-int.37signals.com + - fizzy-lb-402.df-ams-int.37signals.com + labels: + otel_role: load-balancer + otel_service: fizzy-load-balancer + otel_scrape_enabled: true + options: + publish: + - 80:80 + - 443:443 + # NFS mount for certificates + # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 + mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-production-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" + volumes: + - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/saas/config/deploy.staging.yml b/saas/config/deploy.staging.yml new file mode 100644 index 000000000..2c0ae9516 --- /dev/null +++ b/saas/config/deploy.staging.yml @@ -0,0 +1,85 @@ +retain_containers: 1 + +servers: + web: + hosts: + - fizzy-staging-app-101.df-iad-int.37signals.com: df_iad + - fizzy-staging-app-102.df-iad-int.37signals.com: df_iad + - fizzy-staging-app-01.sc-chi-int.37signals.com: sc_chi + - fizzy-staging-app-02.sc-chi-int.37signals.com: sc_chi + - fizzy-staging-app-401.df-ams-int.37signals.com: df_ams + - fizzy-staging-app-402.df-ams-int.37signals.com: df_ams + labels: + otel_scrape_enabled: true + jobs: + hosts: + - fizzy-staging-jobs-101.df-iad-int.37signals.com: df_iad + - fizzy-staging-jobs-102.df-iad-int.37signals.com: df_iad + labels: + otel_scrape_enabled: true + +proxy: + ssl: false + +ssh: + user: app + +env: + clear: + RAILS_ENV: staging + MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary + MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica + MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary + MYSQL_SOLID_QUEUE_HOST: fizzy-staging-mysql-primary + secret: + - RAILS_MASTER_KEY + - MYSQL_ALTER_PASSWORD + - MYSQL_ALTER_USER + - MYSQL_APP_PASSWORD + - MYSQL_APP_USER + - MYSQL_READONLY_PASSWORD + - MYSQL_READONLY_USER + - SECRET_KEY_BASE + - VAPID_PUBLIC_KEY + - VAPID_PRIVATE_KEY + - ACTIVE_STORAGE_ACCESS_KEY_ID + - ACTIVE_STORAGE_SECRET_ACCESS_KEY + - QUEENBEE_API_TOKEN + - SIGNAL_ID_SECRET + - SENTRY_DSN + - 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 + df_iad: + MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-101.df-iad-int.37signals.com + PRIMARY_DATACENTER: true + df_ams: + MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-401.df-ams-int.37signals.com + + +accessories: + load-balancer: + image: basecamp/kamal-proxy:lb + hosts: + - fizzy-staging-lb-01.sc-chi-int.37signals.com + - fizzy-staging-lb-101.df-iad-int.37signals.com + - fizzy-staging-lb-401.df-ams-int.37signals.com + labels: + otel_role: load-balancer + otel_service: fizzy-load-balancer + otel_scrape_enabled: true + options: + publish: + - 80:80 + - 443:443 + # NFS mount for certificates + # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 + mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-staging-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" + volumes: + - load-balancer:/home/kamal-proxy/.config/kamal-proxy diff --git a/saas/config/deploy.yml b/saas/config/deploy.yml new file mode 100644 index 000000000..34ad575dd --- /dev/null +++ b/saas/config/deploy.yml @@ -0,0 +1,33 @@ +service: fizzy +image: basecamp/fizzy +asset_path: /rails/public/assets +hooks_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal", "hooks") %> +secrets_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal/secrets") %> + +servers: + jobs: + cmd: bin/jobs + +volumes: + - fizzy:/rails/storage + +proxy: + ssl: true + +registry: + server: registry.37signals.com + username: robot$harbor-bot + password: + - BASECAMP_REGISTRY_PASSWORD + +builder: + arch: amd64 + dockerfile: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "Dockerfile") %> + secrets: + - GITHUB_TOKEN + remote: ssh://app@docker-builder-102 + local: <%= ENV.fetch("KAMAL_BUILDER_LOCAL", "true") %> + +aliases: + console: app exec -i --reuse -e CONSOLE_USER:<%= ENV["USER"] %> "bin/rails console" + ssh: app exec -i --reuse -e CONSOLE_USER:<%= ENV["USER"] %> /bin/bash diff --git a/saas/config/environments/beta.rb b/saas/config/environments/beta.rb new file mode 100644 index 000000000..11025f141 --- /dev/null +++ b/saas/config/environments/beta.rb @@ -0,0 +1,8 @@ +require_relative "production" + +Rails.application.configure do + config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com" + config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" + config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" } + config.action_controller.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" } +end diff --git a/saas/config/environments/development.rb b/saas/config/environments/development.rb new file mode 100644 index 000000000..ac3f546af --- /dev/null +++ b/saas/config/environments/development.rb @@ -0,0 +1,13 @@ +Rails.application.configure do + # SaaS version of Fizzy is multi-tenanted + config.x.multi_tenant.enabled = true + + if Rails.root.join("tmp/structured-logging.txt").exist? + config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") + end + + if Rails.root.join("tmp/solid-queue.txt").exist? + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } } + end +end diff --git a/saas/config/environments/production.rb b/saas/config/environments/production.rb new file mode 100644 index 000000000..3cad5c015 --- /dev/null +++ b/saas/config/environments/production.rb @@ -0,0 +1,18 @@ +Rails.application.configure do + config.active_storage.service = :purestorage + config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) + + config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" } + config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } + config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } + + # SaaS version of Fizzy is multi-tenanted + config.x.multi_tenant.enabled = true + + # Content Security Policy + config.x.content_security_policy.report_only = false + config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" # gitleaks:allow (public DSN for CSP reports) + config.x.content_security_policy.script_src = "https://challenges.cloudflare.com" + config.x.content_security_policy.frame_src = "https://challenges.cloudflare.com" + config.x.content_security_policy.connect_src = "https://storage.basecamp.com" +end diff --git a/saas/config/environments/staging.rb b/saas/config/environments/staging.rb new file mode 100644 index 000000000..7000d8f59 --- /dev/null +++ b/saas/config/environments/staging.rb @@ -0,0 +1,8 @@ +require_relative "production" + +Rails.application.configure do + config.action_mailer.smtp_settings[:domain] = "app.fizzy-staging.com" + config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" + config.action_mailer.default_url_options = { host: "app.fizzy-staging.com", protocol: "https" } + config.action_controller.default_url_options = { host: "app.fizzy-staging.com", protocol: "https" } +end diff --git a/saas/config/routes.rb b/saas/config/routes.rb new file mode 100644 index 000000000..ece9fef17 --- /dev/null +++ b/saas/config/routes.rb @@ -0,0 +1,13 @@ +Fizzy::Saas::Engine.routes.draw do + Queenbee.routes(self) + + namespace :admin do + mount Audits1984::Engine, at: "/console" + get "stats", to: "stats#show" + 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/config/storage.yml b/saas/config/storage.yml new file mode 100644 index 000000000..a149ccf7b --- /dev/null +++ b/saas/config/storage.yml @@ -0,0 +1,33 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage/files") %> + +local: + service: Disk + root: <%= Rails.root.join("storage", Rails.env, "files") %> + +devminio: + service: S3 + bucket: fizzy-dev-activestorage + endpoint: "http://minio.localhost:39000" + force_path_style: true + request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + region: us-east-1 # default region required for signer + access_key_id: minioadmin + secret_access_key: minioadmin + +# We have "development", "staging", and "production" buckets configured. Note that we don't have a +# "beta" bucket. (As of 2025-06-01.) +<% pure_env = Rails.env.beta? ? "production" : Rails.env %> +purestorage: + service: S3 + bucket: fizzy-<%= pure_env %>-activestorage + endpoint: "https://storage.basecamp.com" + ssl_verify_peer: false # FIXME: using self-signed cert internally + force_path_style: true + request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support + region: us-east-1 # default region required for signer + access_key_id: <%= ENV["ACTIVE_STORAGE_ACCESS_KEY_ID"] %> + secret_access_key: <%= ENV["ACTIVE_STORAGE_SECRET_ACCESS_KEY"] %> diff --git a/saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb b/saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb new file mode 100644 index 000000000..d03cc6248 --- /dev/null +++ b/saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb @@ -0,0 +1,36 @@ +# This migration comes from console1984 (originally 20210517203931) +class CreateConsole1984Tables < ActiveRecord::Migration[7.0] + def change + create_table :console1984_sessions do |t| + t.text :reason + t.references :user, null: false, index: false + t.timestamps + + t.index :created_at + t.index [ :user_id, :created_at ] + end + + create_table :console1984_users do |t| + t.string :username, null: false + t.timestamps + + t.index [:username] + end + + create_table :console1984_commands do |t| + t.text :statements + t.references :sensitive_access + t.references :session, null: false, index: false + t.timestamps + + t.index [ :session_id, :created_at, :sensitive_access_id ], name: "on_session_and_sensitive_chronologically" + end + + create_table :console1984_sensitive_accesses do |t| + t.text :justification + t.references :session, null: false + + t.timestamps + end + end +end diff --git a/saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb b/saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb new file mode 100644 index 000000000..431da136b --- /dev/null +++ b/saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb @@ -0,0 +1,13 @@ +# This migration comes from audits1984 (originally 20210810092639) +class CreateAuditingTables < ActiveRecord::Migration[7.0] + def change + create_table :audits1984_audits do |t| + t.integer :status, default: 0, null: false + t.text :notes + t.references :session, null: false + t.uuid :auditor_id, null: false + + t.timestamps + end + 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/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/migrate/20251215160000_create_account_billing_waivers.rb b/saas/db/migrate/20251215160000_create_account_billing_waivers.rb new file mode 100644 index 000000000..8f3732c09 --- /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 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 new file mode 100644 index 000000000..59c78f4cb --- /dev/null +++ b/saas/db/saas_schema.rb @@ -0,0 +1,88 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do + 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" + 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" + 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 + 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 + t.text "notes" + t.bigint "session_id", null: false + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["session_id"], name: "index_audits1984_audits_on_session_id" + end + + create_table "console1984_commands", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "sensitive_access_id" + t.bigint "session_id", null: false + t.text "statements" + t.datetime "updated_at", null: false + t.index ["sensitive_access_id"], name: "index_console1984_commands_on_sensitive_access_id" + t.index ["session_id", "created_at", "sensitive_access_id"], name: "on_session_and_sensitive_chronologically" + end + + create_table "console1984_sensitive_accesses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "justification" + t.bigint "session_id", null: false + t.datetime "updated_at", null: false + t.index ["session_id"], name: "index_console1984_sensitive_accesses_on_session_id" + end + + create_table "console1984_sessions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "reason" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["created_at"], name: "index_console1984_sessions_on_created_at" + t.index ["user_id", "created_at"], name: "index_console1984_sessions_on_user_id_and_created_at" + end + + create_table "console1984_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "username", null: false + t.index ["username"], name: "index_console1984_users_on_username" + end +end diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev new file mode 100755 index 000000000..2033385ff --- /dev/null +++ b/saas/exe/stripe-dev @@ -0,0 +1,80 @@ +#!/usr/bin/env ruby +# +# 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 \ + --from "Deploy/Fizzy" \ + "Development/STRIPE_SECRET_KEY" \ + "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` + +secrets_json = secrets_escaped.gsub(/\\(.)/, '\1') +secrets = JSON.parse(secrets_json) + +stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"] +stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"] + +# 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}" diff --git a/saas/fizzy-saas.gemspec b/saas/fizzy-saas.gemspec new file mode 100644 index 000000000..1368ef63c --- /dev/null +++ b/saas/fizzy-saas.gemspec @@ -0,0 +1,43 @@ +require_relative "lib/fizzy/saas/version" + +Gem::Specification.new do |spec| + spec.name = "fizzy-saas" + spec.version = Fizzy::Saas::VERSION + spec.authors = [ "Mike Dalessio" ] + spec.email = [ "mike@37signals.com" ] + spec.homepage = "https://github.com/basecamp/fizzy-saas" + spec.summary = "37signals SaaS companion for Fizzy" + spec.description = "Rails engine that bundles with Fizzy to offer the hosted version at https://app.fizzy.do" + spec.license = "O'Saasy" + + # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host" + # to allow pushing to a single host or delete this section to allow pushing to any host. + spec.metadata["allowed_push_host"] = "https://rubygems.org" + + spec.metadata["homepage_uri"] = spec.homepage + 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,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" + spec.add_dependency "sentry-ruby" + spec.add_dependency "sentry-rails" + spec.add_dependency "yabeda" + spec.add_dependency "yabeda-actioncable" + spec.add_dependency "yabeda-activejob" + spec.add_dependency "yabeda-gc" + spec.add_dependency "yabeda-http_requests" + spec.add_dependency "yabeda-prometheus-mmap" + spec.add_dependency "yabeda-puma-plugin" + spec.add_dependency "yabeda-rails", ">= 0.10" + spec.add_dependency "prometheus-client-mmap", "~> 1.4.0" + spec.add_dependency "console1984" + spec.add_dependency "audits1984" +end diff --git a/saas/lib/fizzy/saas.rb b/saas/lib/fizzy/saas.rb new file mode 100644 index 000000000..ab6aef29b --- /dev/null +++ b/saas/lib/fizzy/saas.rb @@ -0,0 +1,12 @@ +require "fizzy/saas/version" +require "fizzy/saas/engine" + +module Fizzy + module Saas + def self.append_test_paths + engine_test_path = Engine.root.join("test") + ENV["DEFAULT_TEST"] = "{#{engine_test_path},test}/**/*_test.rb" + ENV["DEFAULT_TEST_EXCLUDE"] = "{#{engine_test_path},test}/{system,dummy,fixtures}/**/*_test.rb" + end + end +end diff --git a/saas/lib/fizzy/saas/authorization.rb b/saas/lib/fizzy/saas/authorization.rb new file mode 100644 index 000000000..2309d1352 --- /dev/null +++ b/saas/lib/fizzy/saas/authorization.rb @@ -0,0 +1,28 @@ +module Fizzy + module Saas + module Authorization + module Controller + extend ActiveSupport::Concern + + included do + before_action :ensure_only_employees_can_access_non_production_remote_environments, if: :authenticated? + end + + private + def ensure_only_employees_can_access_non_production_remote_environments + head :forbidden if Rails.env.staging? && !Current.identity.employee? + end + end + + module Identity + extend ActiveSupport::Concern + + EMPLOYEE_DOMAINS = [ "@37signals.com", "@basecamp.com" ].freeze + + def employee? + email_address.end_with?(*EMPLOYEE_DOMAINS) + end + end + end + end +end diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb new file mode 100644 index 000000000..532f0a384 --- /dev/null +++ b/saas/lib/fizzy/saas/engine.rb @@ -0,0 +1,151 @@ +require_relative "transaction_pinning" +require_relative "signup" +require_relative "authorization" +require_relative "../../rails_ext/active_record_tasks_database_tasks.rb" + +module Fizzy + module Saas + class Engine < ::Rails::Engine + # 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" + end + end + + initializer "fizzy_saas.transaction_pinning" do |app| + app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) + end + + initializer "fizzy_saas.solid_queue" do + SolidQueue.on_start do + Process.warmup + Yabeda::Prometheus::Exporter.start_metrics_server! + end + end + + initializer "fizzy_saas.logging.session" do |app| + ActiveSupport.on_load(:action_controller_base) do + before_action do + if Current.identity.present? + logger.struct(authentication: { identity: { id: Current.identity.id } }) + end + + if Current.account.present? + logger.struct(account: { queenbee_id: Current.account.external_account_id }) + end + end + end + end + + # Load test mocks automatically in test environment + initializer "fizzy_saas.test_mocks", after: :load_config_initializers do + if Rails.env.test? + require_relative "testing" + 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| + config.dsn = ENV["SENTRY_DSN"] + config.breadcrumbs_logger = %i[ active_support_logger http_logger ] + config.send_default_pii = false + config.release = ENV["KAMAL_VERSION"] + config.excluded_exceptions += [ "ActiveRecord::ConcurrentMigrationError" ] + + # Receive Rails.error.report and retry_on/discard_on report: true + config.rails.register_error_subscriber = true + end + end + end + + initializer "fizzy_saas.yabeda" do + require "prometheus/client/support/puma" + + Prometheus::Client.configuration.logger = Rails.logger + Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider) + Yabeda::Rails.config.controller_name_case = :camel + Yabeda::Rails.config.ignore_actions = %w[ + Rails::HealthController#show + ] + + Yabeda::ActiveJob.install! + + require "yabeda/solid_queue" + Yabeda::SolidQueue.install! + + Yabeda::ActionCable.configure do |config| + config.channel_class_name = "ActionCable::Channel::Base" + end + + require_relative "metrics" + end + + config.before_initialize do + config.console1984.protected_environments = %i[ production beta staging ] + config.console1984.ask_for_username_if_empty = true + config.console1984.base_record_class = "::SaasRecord" + + config.audits1984.base_controller_class = "::SaasAdminController" + config.audits1984.auditor_class = "::Identity" + config.audits1984.auditor_name_attribute = :email_address + + if config.console1984.protected_environments.include?(Rails.env.to_sym) + config.active_record.encryption.primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") + config.active_record.encryption.deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") + config.active_record.encryption.key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") + end + end + + config.to_prepare do + ::Account.include Account::Billing, Account::Limited + ::Signup.prepend Fizzy::Saas::Signup + CardsController.include(Card::LimitedCreation) + Cards::PublishesController.include(Card::LimitedPublishing) + + Queenbee::Subscription.short_names = Subscription::SHORT_NAMES + + # Default to local dev QB token if not set + Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" } # gitleaks:allow development-only token + + Subscription::SHORT_NAMES.each do |short_name| + const_name = "#{short_name}Subscription" + ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name) + ::Object.const_set const_name, Subscription.const_get(short_name, false) + end + + ::ApplicationController.include Fizzy::Saas::Authorization::Controller + ::Identity.include Fizzy::Saas::Authorization::Identity + end + end + end +end diff --git a/saas/lib/fizzy/saas/metrics.rb b/saas/lib/fizzy/saas/metrics.rb new file mode 100644 index 000000000..80a2bc194 --- /dev/null +++ b/saas/lib/fizzy/saas/metrics.rb @@ -0,0 +1,13 @@ +Yabeda.configure do + SHORT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 ] + + group :fizzy do + counter :replica_stale, + comment: "Number of requests served from a stale replica" + + histogram :replica_wait, + unit: :seconds, + comment: "Time spent waiting for replica to catch up with transaction", + buckets: SHORT_HISTOGRAM_BUCKETS + end +end diff --git a/saas/lib/fizzy/saas/signup.rb b/saas/lib/fizzy/saas/signup.rb new file mode 100644 index 000000000..fdf46cb1b --- /dev/null +++ b/saas/lib/fizzy/saas/signup.rb @@ -0,0 +1,43 @@ +module Fizzy + module Saas + module Signup + extend ActiveSupport::Concern + + included do + attr_reader :queenbee_account + end + + private + def create_tenant + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + @queenbee_account.id.to_s + end + + def handle_account_creation_error(error) + @queenbee_account&.cancel + end + + def queenbee_account_attributes + {}.tap do |attributes| + attributes[:product_name] = "fizzy" + attributes[:name] = generate_account_name + attributes[:owner_name] = full_name + attributes[:owner_email] = email_address + + attributes[:trial] = true + attributes[:subscription] = subscription_attributes + attributes[:remote_request] = request_attributes + + # # TODO: Terms of Service + # attributes[:terms_of_service] = true + + # We've confirmed the email + attributes[:auto_allow] = true + + # Tell Queenbee to skip the request to create a local account. We've created it ourselves. + attributes[:skip_remote] = true + end + end + end + end +end diff --git a/saas/lib/fizzy/saas/testing.rb b/saas/lib/fizzy/saas/testing.rb new file mode 100644 index 000000000..3b40f027a --- /dev/null +++ b/saas/lib/fizzy/saas/testing.rb @@ -0,0 +1,20 @@ +require "queenbee/testing/mocks" + +Queenbee::Remote::Account.class_eval do + # because we use the account ID as the tenant name, we need it to be unique in each test to avoid + # parallelized tests clobbering each other. + def next_id + 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/lib/fizzy/saas/transaction_pinning.rb b/saas/lib/fizzy/saas/transaction_pinning.rb new file mode 100644 index 000000000..ed3cf2060 --- /dev/null +++ b/saas/lib/fizzy/saas/transaction_pinning.rb @@ -0,0 +1,65 @@ +module TransactionPinning + class Middleware + SESSION_KEY = :last_txn + DEFAULT_MAX_WAIT = 0.25 + + def initialize(app) + @app = app + @timeout = Rails.application.config.x.transaction_pinning&.timeout&.to_f || DEFAULT_MAX_WAIT + end + + def call(env) + request = ActionDispatch::Request.new(env) + replica_metrics = {} + + if ApplicationRecord.current_role == :reading + wait_for_replica_catchup(request, replica_metrics) + end + + status, headers, body = @app.call(env) + headers.merge!(replica_metrics.transform_values(&:to_s)) + + if ApplicationRecord.current_role == :writing + capture_transaction_id(request) + end + + [ status, headers, body ] + end + + private + def wait_for_replica_catchup(request, replica_metrics) + if last_txn = request.session[SESSION_KEY].presence + has_transaction = tracking_replica_wait_time(replica_metrics) do + replica_has_transaction(last_txn) + end + + unless has_transaction + Yabeda.fizzy.replica_stale.increment + replica_metrics["X-Replica-Stale"] = true + end + end + end + + def capture_transaction_id(request) + request.session[SESSION_KEY] = ApplicationRecord.connection.show_variable("global.gtid_executed") + end + + def replica_has_transaction(txn) + sql = ApplicationRecord.sanitize_sql_array([ "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)", txn, @timeout ]) + ApplicationRecord.connection.select_value(sql) == 0 + rescue => e + Sentry.capture_exception(e, extra: { gtid: txn }) + true # Treat as if we're up to date, since we don't know + end + + def tracking_replica_wait_time(replica_metrics) + started_at = Time.current + + Yabeda.fizzy.replica_wait.measure do + yield + end.tap do + replica_metrics["X-Replica-Wait"] = Time.current - started_at + end + end + end +end diff --git a/saas/lib/fizzy/saas/version.rb b/saas/lib/fizzy/saas/version.rb new file mode 100644 index 000000000..7a95d2d05 --- /dev/null +++ b/saas/lib/fizzy/saas/version.rb @@ -0,0 +1,5 @@ +module Fizzy + module Saas + VERSION = "0.1.0" + end +end diff --git a/saas/lib/rails_ext/active_record_tasks_database_tasks.rb b/saas/lib/rails_ext/active_record_tasks_database_tasks.rb new file mode 100644 index 000000000..bafa27464 --- /dev/null +++ b/saas/lib/rails_ext/active_record_tasks_database_tasks.rb @@ -0,0 +1,23 @@ +module ActiveRecordTasksDatabaseTasksExtension + extend ActiveSupport::Concern + + class_methods do + # proposed upstream in https://github.com/rails/rails/pull/56290 + def schema_dump_path(db_config, format = db_config.schema_format) + return ENV["SCHEMA"] if ENV["SCHEMA"] + + filename = db_config.schema_dump(format) + return unless filename + + if Pathname.new(filename).absolute? + filename + else + super + end + end + end +end + +ActiveSupport.on_load(:active_record) do + ActiveRecord::Tasks::DatabaseTasks.include(ActiveRecordTasksDatabaseTasksExtension) +end diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake new file mode 100644 index 000000000..ada09dabd --- /dev/null +++ b/saas/lib/tasks/fizzy/saas_tasks.rake @@ -0,0 +1,10 @@ +require "rake/testtask" + +namespace :test do + desc "Run tests for fizzy-saas gem" + Rake::TestTask.new(saas: :environment) do |t| + t.libs << "test" + t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")] + t.warning = false + end +end diff --git a/saas/lib/yabeda/solid_queue.rb b/saas/lib/yabeda/solid_queue.rb new file mode 100644 index 000000000..082ac8936 --- /dev/null +++ b/saas/lib/yabeda/solid_queue.rb @@ -0,0 +1,27 @@ +module Yabeda + module SolidQueue + def self.install! + Yabeda.configure do + group :solid_queue + + gauge :jobs_failed_count, comment: "Number of failed jobs" + gauge :jobs_unreleased_count, comment: "Number of claimed jobs that don't belong to any process" + gauge :jobs_scheduled_and_delayed_count, comment: "Number of scheduled jobs that have over 5 minutes delay" + gauge :recurring_tasks_count, comment: "Number of recurring jobs scheduled" + gauge :recurring_tasks_delayed_count, comment: "Number of recurring jobs that haven't been enqueued within their schedule" + + collect do + if ::SolidQueue.supervisor? + solid_queue.jobs_failed_count.set({}, ::SolidQueue::FailedExecution.count) + solid_queue.jobs_unreleased_count.set({}, ::SolidQueue::ClaimedExecution.where(process: nil).count) + solid_queue.jobs_scheduled_and_delayed_count.set({}, ::SolidQueue::ScheduledExecution.where(scheduled_at: ..5.minutes.ago).count) + solid_queue.recurring_tasks_count.set({}, ::SolidQueue::RecurringTask.count) + solid_queue.recurring_tasks_delayed_count.set({}, ::SolidQueue::RecurringTask.count do |task| + task.last_enqueued_time.present? && (task.previous_time - task.last_enqueued_time) > 5.minutes + end) + end + end + end + end + end +end diff --git a/saas/script/configure-lb-beta.sh b/saas/script/configure-lb-beta.sh new file mode 100755 index 000000000..73844ee2b --- /dev/null +++ b/saas/script/configure-lb-beta.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +# fizzy-beta-lb-01.sc-chi-int.37signals.com +# +ssh app@fizzy-beta-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=fizzy-beta.37signals.com \ + --writer-affinity-timeout=0 \ + --target=fizzy-beta-app-101.df-iad-int.37signals.com \ + --read-target=fizzy-beta-app-01.sc-chi-int.37signals.com diff --git a/saas/script/configure-lb-production.sh b/saas/script/configure-lb-production.sh new file mode 100755 index 000000000..a3e914d3f --- /dev/null +++ b/saas/script/configure-lb-production.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e + +# fizzy-lb-101.df-iad-int.37signals.com +# +ssh app@fizzy-lb-101.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com + + +# fizzy-lb-102.df-iad-int.37signals.com +# +ssh app@fizzy-lb-102.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com + + +# fizzy-lb-01.sc-chi-int.37signals.com +# +ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-app-02.sc-chi-int.37signals.com + + +# fizzy-lb-02.sc-chi-int.37signals.com +# +ssh app@fizzy-lb-02.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-app-02.sc-chi-int.37signals.com + + +# fizzy-lb-401.df-ams-int.37signals.com +# +ssh app@fizzy-lb-401.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-app-402.df-ams-int.37signals.com + + +# fizzy-lb-402.df-ams-int.37signals.com +# +ssh app@fizzy-lb-402.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy.do \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-app-101.df-iad-int.37signals.com \ + --target=fizzy-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-app-402.df-ams-int.37signals.com diff --git a/saas/script/configure-lb-staging.sh b/saas/script/configure-lb-staging.sh new file mode 100755 index 000000000..770014403 --- /dev/null +++ b/saas/script/configure-lb-staging.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +set -e + +# fizzy-staging-lb-01.sc-chi-int.37signals.com +# +ssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy-staging.com \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-staging-app-01.sc-chi-int.37signals.com \ + --read-target=fizzy-staging-app-02.sc-chi-int.37signals.com + +# fizzy-staging-lb-101.df-iad-int.37signals.com +# +ssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy-staging.com \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com + +# fizzy-staging-lb-401.df-ams-int.37signals.com +# +ssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \ + docker exec fizzy-load-balancer \ + kamal-proxy deploy fizzy \ + --force \ + --tls \ + --host=app.fizzy-staging.com \ + --writer-affinity-timeout=0 \ + --tls-acme-cache-path=/certificates \ + --target=fizzy-staging-app-101.df-iad-int.37signals.com \ + --target=fizzy-staging-app-102.df-iad-int.37signals.com \ + --read-target=fizzy-staging-app-401.df-ams-int.37signals.com \ + --read-target=fizzy-staging-app-402.df-ams-int.37signals.com + diff --git a/saas/test/controllers/.keep b/saas/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb 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..d98ac1d91 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -0,0 +1,46 @@ +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..0b830919a --- /dev/null +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest + 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 override card count" do + sign_in_as :david + + untenanted do + 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.billed_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/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb new file mode 100644 index 000000000..8f7d14199 --- /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 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 new file mode 100644 index 000000000..d7de964c1 --- /dev/null +++ b/saas/test/controllers/admin/stats_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Admin::StatsControllerTest < ActionDispatch::IntegrationTest + 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_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/non_production_remote_access_test.rb b/saas/test/controllers/non_production_remote_access_test.rb new file mode 100644 index 000000000..40a9520d8 --- /dev/null +++ b/saas/test/controllers/non_production_remote_access_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest + test "employee can access in staging environment" do + assert_predicate identities(:david), :employee? + + sign_in_as :david + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) + get cards_path + assert_response :success + end + + test "non-employee cannot access in staging environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) + get cards_path + assert_response :forbidden + end + + test "non-employee can access in production environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production")) + get cards_path + assert_response :success + end + + test "non-employee can access in beta environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("beta")) + get cards_path + assert_response :success + end + + test "non-employee can access in local environment" do + identities(:jz).update!(email_address: "david@example.com") + assert_not_predicate identities(:jz), :employee? + + sign_in_as :jz + + get cards_path + assert_response :success + 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..92f6b7508 --- /dev/null +++ b/saas/test/controllers/stripe/webhooks_controller_test.rb @@ -0,0 +1,100 @@ +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", 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", + 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) + Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999)) + + 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 and syncs next amount due" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + 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) + Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999)) + + post stripe_webhooks_path + + assert_response :ok + @subscription.reload + assert_equal "past_due", @subscription.status + assert_equal 1999, @subscription.next_amount_due_in_cents + end + + test "subscription deleted cancels subscription" do + @subscription.update!(stripe_subscription_id: "sub_123", status: "active") + + 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 + + assert_response :ok + @subscription.reload + assert_equal "canceled", @subscription.status + assert_nil @subscription.stripe_subscription_id + assert_nil @subscription.next_amount_due_in_cents + 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/fixtures/files/.keep b/saas/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/helpers/.keep b/saas/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/integration/.keep b/saas/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/mailers/.keep b/saas/test/mailers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb new file mode 100644 index 000000000..7283f47da --- /dev/null +++ b/saas/test/models/account/billing_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class Account::BillingTest < ActiveSupport::TestCase + test "plan reflects active subscription" do + account = accounts(:initech) + + # No 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_equal Plan.free, account.plan + + # Active subscription exists + account.subscription.update!(status: "active") + assert_equal Plan.paid, account.plan + 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 new file mode 100644 index 000000000..335d6a37c --- /dev/null +++ b/saas/test/models/account/limited_test.rb @@ -0,0 +1,76 @@ +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 + + 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 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/identity_test.rb b/saas/test/models/identity_test.rb new file mode 100644 index 000000000..81e3b82c1 --- /dev/null +++ b/saas/test/models/identity_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class Fizzy::Saas::IdentityTest < ActiveSupport::TestCase + test "#employee? returns true for 37signals.com domains" do + identity = Identity.new(email_address: "mike@37signals.com") + assert_predicate identity, :employee? + end + + test "#employee? returns true for basecamp.com domains" do + identity = Identity.new(email_address: "mike@basecamp.com") + assert_predicate identity, :employee? + end + + test "#employee? returns false for other domains" do + identity = Identity.new(email_address: "mike@example.com") + assert_not_predicate identity, :employee? + 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 diff --git a/saas/test/models/signup_test.rb b/saas/test/models/signup_test.rb new file mode 100644 index 000000000..b06edf44c --- /dev/null +++ b/saas/test/models/signup_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class Fizzy::Saas::SignupTest < ActiveSupport::TestCase + test "#complete creates queenbee account and uses its id as tenant" do + queenbee_account = mock("queenbee_account") + queenbee_account.stubs(:id).returns(123456) + + Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account) + Account.any_instance.expects(:setup_customer_template).once + + Current.without_account do + assert_changes -> { Account.count }, +1 do + sequence_value_before = Account::ExternalIdSequence.value + + signup = Signup.new( + full_name: "Kevin", + identity: identities(:kevin) + ) + + assert signup.complete + + assert signup.account + assert_equal 123456, signup.account.external_account_id + assert_equal sequence_value_before, Account::ExternalIdSequence.value + end + end + end + + test "#complete calls cancel on queenbee account when account creation fails" do + queenbee_account = mock("queenbee_account") + queenbee_account.stubs(:id).returns(789012) + queenbee_account.expects(:cancel).once + + Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account) + Account.any_instance.stubs(:setup_customer_template).raises(StandardError.new("Account setup failed")) + + Current.without_account do + signup = Signup.new( + full_name: "Kevin", + identity: identities(:kevin) + ) + + assert_not signup.complete + assert_includes signup.errors[:base], "Something went wrong, and we couldn't create your account. Please give it another try." + end + end +end