Merge fizzy-saas repository into saas/

Consolidates fizzy-saas back into the main repo as a vendored gem.
Full commit history preserved with files under saas/ prefix.

* saas-import/main: (131 commits)
  Format
  Remove nested if
  Move presentation method to a regular view helper
  Format
  Rename to stripe_session to avoid confusions
  Extract method
  Make active_subscription a private method
  More concise
  Prefer if/else over early return
  Rename param to remove redundant bit
  This is needed for vat id collection
  Enable tax id collection
  Grab and show the next amount to pay from Stripe
  Add system to comp accounts
  Add record to track overridden limits
  Enable automatic taxes and calculate based on mandatory billing address
  Remove redundant condition
  Don't prevent creating drafts, or you won't get to see the upgrade banner
  Give this some padding
  Show different things to non-admins
  ...
This commit is contained in:
Jeremy Daer
2025-12-16 12:02:48 -08:00
109 changed files with 3543 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
name: Lint
on:
pull_request:
permissions:
contents: read
jobs:
rubocop:
name: Rubocop
runs-on: ubuntu-latest
env:
BUNDLE_GITHUB__COM: ${{ format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) }}
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop
+10
View File
@@ -0,0 +1,10 @@
/.bundle/
/doc/
/log/*.log
/pkg/
/tmp/
/test/dummy/db/*.sqlite3
/test/dummy/db/*.sqlite3-*
/test/dummy/log/*.log
/test/dummy/storage/
/test/dummy/tmp/
+17
View File
@@ -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
+82
View File
@@ -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
+26
View File
@@ -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)
+26
View File
@@ -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)
+26
View File
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
AllCops:
Exclude:
- 'db/migrate/**/*'
- 'db/schema*.rb'
+1
View File
@@ -0,0 +1 @@
3.4.7
+82
View File
@@ -0,0 +1,82 @@
# 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
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"]
+9
View File
@@ -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
+291
View File
@@ -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
+10
View File
@@ -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.
+79
View File
@@ -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).
+8
View File
@@ -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"
@@ -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);
}
}
}
@@ -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
@@ -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
@@ -0,0 +1,5 @@
class Admin::AccountSearchesController < AdminController
def create
redirect_to saas.edit_admin_account_path(params[:q])
end
end
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
class SaasAdminController < ::AdminController
private
def find_current_auditor
Current.identity
end
end
@@ -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
+15
View File
@@ -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 <b>#{ number_to_currency(subscription.next_amount_due) }</b> will be billed on".html_safe
end
end
end
+42
View File
@@ -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
@@ -0,0 +1,7 @@
class Account::BillingWaiver < SaasRecord
belongs_to :account
def subscription
@subscription ||= Account::Subscription.new(plan_key: Plan.paid.key)
end
end
+35
View File
@@ -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
@@ -0,0 +1,4 @@
# To ease testing of limits
class Account::OverriddenLimits < SaasRecord
belongs_to :account
end
+21
View File
@@ -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
+50
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
class SaasRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :saas, reading: :saas }
end
+13
View File
@@ -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
@@ -0,0 +1,7 @@
<p hidden>Status: <strong><%= subscription.status.titleize %></strong></p>
<% if subscription.current_period_end %>
<p class="txt-medium margin-none-block-start"><%= subscription_period_end_action(subscription) %> <strong><%= subscription.current_period_end.to_date.to_fs(:long) %></strong></p>
<% end %>
<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %>
@@ -0,0 +1,28 @@
<% if Current.user.admin? && !Current.account.comped? %>
<section class="panel panel--wide settings-subscription__panel shadow center margin-block-double">
<h2 id="subscription" class="divider settings-subscription__divider txt-small txt-uppercase margin-none">Subscription</h2>
<% if Current.account.plan.free? %>
<% if Current.account.exceeding_card_limit? %>
<h3 class="margin-block-start margin-block-end-half txt-large font-weight-black">Youve used up your <u><%= Plan.free.card_limit %></u> free cards</h3>
<% else %>
<h3 class="margin-block-start margin-block-end-half txt-large font-weight-black">Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= Plan.free.card_limit %></h3>
<% end %>
<p class="margin-none-block-start">If youd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, its only <strong>$<%= Plan.paid.price %>/month</strong> for <strong>unlimited cards</strong> + <strong>unlimited users</strong>. You'll also get <strong><%= plan_storage_limit(Plan.paid) %></strong> of storage. </p>
<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<p>Cancel anytime, no contracts, take your data with you whenever.</p>
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= Plan.free.card_limit %> cards, unlimited users, and <%= plan_storage_limit(Plan.free) %> of storage.</p>
<% else %>
<h3 class="margin-block-start margin-block-end-half txt-x-large font-weight-black">Thank you for buying Fizzy</h3>
<% if Current.account.subscription %>
<%= render "account/settings/subscription", subscription: Current.account.subscription %>
<p class="settings-subscription__footer txt-small">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= plan_storage_limit(Plan.paid) %> of storage.</p>
<% end %>
<% end %>
</section>
<% end %>
@@ -0,0 +1,12 @@
<div class="settings-subscription__notch card-perma__notch card-perma__notch--bottom">
<% if Current.user.admin? %>
<strong>Youve used your <u><%= Plan.free.card_limit %></u> free cards.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to Unlimited</span>
<% end %>
<% else %>
<div class="pad-inline pad-block-half">
<strong>This account has used <u><%= Plan.free.card_limit %></u> free cards. Upgrade to get more.</strong>
</div>
<% end %>
</div>
@@ -0,0 +1,9 @@
<% @page_title = "Thank you" %>
<% if @stripe_session&.payment_status == "paid" %>
<section class="panel panel--wide panel--centered center borderless">
<h1 class="txt-x-large font-weight-black margin-none">Thanks for buying Fizzy!</h1>
<p class="txt-medium margin-none-block-start">Your payment was successful. Youre now on the <strong><%= Current.account.plan.name %></strong> plan.</p>
<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
</section>
<% end %>
@@ -0,0 +1,39 @@
<div class="panel shadow center" style="--panel-size: 50ch;">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-block-none">Edit Account <%= @account.external_account_id %></h1>
<dl class="flex flex-column gap-half margin-block-none">
<div class="flex gap-half">
<dt class="font-weight-bold">Name:</dt>
<dd class="margin-inline-start-none"><%= @account.name %></dd>
</div>
<div class="flex gap-half">
<dt class="font-weight-bold">Actual card count:</dt>
<dd class="margin-inline-start-none"><%= @account.cards_count %></dd>
</div>
</dl>
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
<div class="flex flex-column gap-half">
<%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %>
<div class="flex align-center gap input input--actor">
<%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %>
</div>
</div>
<button type="submit" class="btn btn--reversed">Save</button>
<% 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" %>
</div>
</div>
@@ -0,0 +1,6 @@
<h1>Find Account</h1>
<%= form_with url: saas.admin_account_search_path do |form| %>
<%= form.text_field :q, placeholder: "Account ID", autofocus: true %>
<%= form.submit "Search" %>
<% end %>
+126
View File
@@ -0,0 +1,126 @@
<% @page_title = "Account Statistics" %>
<% content_for :header do %>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<section class="settings">
<div class="settings__panel panel shadow center">
<div class="flex flex-column gap margin-block-half">
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Accounts Created</h2>
</header>
<div class="flex gap-half">
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">Total</div>
<div class="txt-large">
<strong><%= @accounts_total %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">7 days</div>
<div class="txt-large">
<strong><%= @accounts_last_7_days %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">24 hours</div>
<div class="txt-large">
<strong><%= @accounts_last_24_hours %></strong>
</div>
</div>
</div>
</div>
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Identities Created</h2>
</header>
<div class="flex gap-half">
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">Total</div>
<div class="txt-large">
<strong><%= @identities_total %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">7 days</div>
<div class="txt-large">
<strong><%= @identities_last_7_days %></strong>
</div>
</div>
<div class="flex flex-column gap-quarter flex-item-grow">
<div class="txt-x-small">24 hours</div>
<div class="txt-large">
<strong><%= @identities_last_24_hours %></strong>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="settings__panel panel shadow center">
<header>
<h2 class="divider txt-medium margin-block-start">
10 Most Recent Signups
</h2>
</header>
<ul class="margin-block-half">
<% @recent_accounts.each do |account| %>
<% admin_user = account.users.owner.first %>
<li class="flex align-start gap-half margin-block-start-half">
<div
class="flex-item-grow min-width overflow-ellipsis"
style="text-align: left"
>
<strong><%= account.name %></strong>
<br>
<span class="txt-x-small txt-ink-medium">
#<%= account.external_account_id %> •
<%= admin_user&.identity&.email_address || "No admin" %>
</span>
</div>
<div class="txt-medium txt-ink-medium" style="text-align: right">
<%= time_ago_in_words(account.created_at) %> ago
</div>
</li>
<% end %>
</ul>
</div>
<div class="settings__panel panel shadow center">
<header>
<h2 class="divider txt-medium margin-block-start">
Top 20 Accounts by Card Count
</h2>
</header>
<ul class="margin-block-half">
<% @top_accounts.each do |account| %>
<% admin_user = account.users.owner.first %>
<li class="flex align-start gap-half margin-block-start-half">
<div
class="flex-item-grow min-width overflow-ellipsis"
style="text-align: left"
>
<strong><%= account.name %></strong>
<br>
<span class="txt-x-small txt-ink-medium">
#<%= account.external_account_id %> •
<%= admin_user&.identity&.email_address || "No admin" %>
</span>
</div>
<div class="txt-medium">
<strong><%= number_with_delimiter(account.cards_count) %></strong>
<span class="txt-x-small txt-ink-medium">cards</span>
</div>
</li>
<% end %>
</ul>
</div>
</section>
@@ -0,0 +1,5 @@
<% if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/upgrade" %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
@@ -0,0 +1,9 @@
<% if Current.account.nearing_plan_cards_limit? %>
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
<% if Current.user.admin? %>
Youve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong><%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %></strong>.
<% else %>
This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong>Upgrade soon</strong>
<% end %>
</div>
<% end %>
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Fizzy saas</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%= stylesheet_link_tag "fizzy/saas/application", media: "all" %>
</head>
<body>
<%= yield %>
</body>
</html>
@@ -0,0 +1,30 @@
<% @page_title = "Complete your sign-up" %>
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
<h1 class="txt-x-large font-weight-black margin-block-end"><%= @page_title %></h1>
<%= 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 %>
<p>Youre one step away. Just enter your name to get your own Fizzy account.</p>
<% if @signup.errors.any? %>
<div class="margin-block-half">
<ul class="margin-block-none txt-negative txt-small">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--link center" data-form-target="submit">
<span>Continue</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+28
View File
@@ -0,0 +1,28 @@
<% @page_title = "Sign up for Fizzy" %>
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
<h1 class="txt-xx-large margin-block-end-double">Sign up</h1>
<%= 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? %>
<div class="margin-block-half">
<ul class="margin-block-none txt-negative txt-small">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--link center" data-form-target="submit">
<span>Continue</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
Executable
+14
View File
@@ -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"
+8
View File
@@ -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")
Executable
+12
View File
@@ -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"
+125
View File
@@ -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
+68
View File
@@ -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
+88
View File
@@ -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
+85
View File
@@ -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
+33
View File
@@ -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
+8
View File
@@ -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
+13
View File
@@ -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
+18
View File
@@ -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"
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
+8
View File
@@ -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
+13
View File
@@ -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
+33
View File
@@ -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"] %>
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2]
def change
add_column :account_subscriptions, :next_amount_due_in_cents, :integer
end
end
+88
View File
@@ -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
+80
View File
@@ -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}"
+43
View File
@@ -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
+12
View File
@@ -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
+28
View File
@@ -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
+151
View File
@@ -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
+13
View File
@@ -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
+43
View File
@@ -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
+20
View File
@@ -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)
@@ -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
+5
View File
@@ -0,0 +1,5 @@
module Fizzy
module Saas
VERSION = "0.1.0"
end
end
@@ -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
+10
View File
@@ -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
+27
View File
@@ -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
+15
View File
@@ -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
+94
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+11
View File
@@ -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 %>
View File

Some files were not shown because too many files have changed in this diff Show More