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:
Vendored
+25
@@ -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
|
||||||
@@ -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/
|
||||||
Executable
+17
@@ -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
|
||||||
Executable
+82
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.4.7
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class SaasRecord < ActiveRecord::Base
|
||||||
|
self.abstract_class = true
|
||||||
|
|
||||||
|
connects_to database: { writing: :saas, reading: :saas }
|
||||||
|
end
|
||||||
@@ -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">You’ve 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">You’ve 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 you’d like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it’s 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 you’re 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 you’re 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>You’ve 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. You’re 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 %>
|
||||||
@@ -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? %>
|
||||||
|
You’ve 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>You’re 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 %>
|
||||||
@@ -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
@@ -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"
|
||||||
Executable
+8
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2]
|
||||||
|
def change
|
||||||
|
add_column :account_subscriptions, :next_amount_due_in_cents, :integer
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
Executable
+80
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+15
@@ -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
|
||||||
Executable
+94
@@ -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
|
||||||
Executable
+47
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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 %>
|
||||||
Vendored
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user