Merge branch 'main' into mobile/bridge-button-slot

* main: (33 commits)
  Remove payment/subscription system and card/storage limits
  Conditionally disable peer verification for ZIP streaming
  Prevent HTML injection through filenames
  Revert "Configure Lexxy to add extra spacing between block elements"
  Configure Lexxy to add extra spacing between block elements
  SaaS usage reporting (#2690)
  Update app/models/user/named.rb
  Prevent line breaks in the middle of familiar_name
  Tweak the layout
  Set the list of importable models based on the record sets in the manifest
  Validate polymorphic types against importable models allowlist
  Fix that the "maybe" stream wouldn't be replaced after triaging a card
  Use x_user_agent cookie for platform detection in Hotwire Native
  Add JSON response format to entropy endpoints (#2673)
  Fix path traversal vulnerability in ActiveStorage blob key import
  Enable CORS fetch mode for storage requests
  Revert CORS fetch mode for Active Storage
  Use CORS fetch mode for Active Storage to enable `maxEntrySize` checks
  Only start offline mode when signed in
  Exclude service worker and edit/pin/watch/new pages from main cache
  ...
This commit is contained in:
Jay Ohms
2026-03-17 11:24:39 -04:00
117 changed files with 887 additions and 2251 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ gem "rails", github: "rails/rails", branch: "main"
gem "importmap-rails"
gem "propshaft"
gem "stimulus-rails"
gem "turbo-rails"
gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache"
# Deployment and drivers
gem "bootsnap", require: false
+10 -4
View File
@@ -11,6 +11,15 @@ GIT
specs:
useragent (0.16.11)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -461,9 +470,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -539,7 +545,7 @@ DEPENDENCIES
stimulus-rails
thruster
trilogy (~> 2.10)
turbo-rails
turbo-rails!
useragent!
vcr
web-console!
-1
View File
@@ -5,7 +5,6 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "activeresource", require: "active_resource"
gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing
gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
+10 -6
View File
@@ -60,6 +60,15 @@ GIT
rails (>= 6.1)
yabeda (~> 0.6)
GIT
remote: https://github.com/hotwired/turbo-rails.git
revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -622,7 +631,6 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
stripe (18.0.1)
thor (1.5.0)
thruster (0.1.18)
thruster (0.1.18-aarch64-linux)
@@ -633,9 +641,6 @@ GEM
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
@@ -749,10 +754,9 @@ DEPENDENCIES
sqlite3 (>= 2.0)
stackprof
stimulus-rails
stripe (~> 18.0)
thruster
trilogy (~> 2.10)
turbo-rails
turbo-rails!
useragent!
vcr
web-console!
+60 -17
View File
@@ -156,37 +156,74 @@
@media (max-width: 639px) {
--meta-spacer-block: 0.75ch;
inline-size: 100%;
grid-template-areas:
"avatars-author text-added"
"avatars-author text-author"
"text-updated text-updated"
"text-assignees text-assignees"
"avatars-assignees avatars-assignees";
grid-template-columns: auto 1fr;
min-inline-size: 0;
gap: calc(var(--meta-spacer-block) / 2);
display: flex;
flex-wrap: wrap;
.card__meta-text {
border: 0;
padding: 0;
}
.card__meta-text + .card__meta-text {
.card__meta-avatars--author {
--btn-size: 1.5em;
display: initial;
margin-inline-end: unset;
order: 3;
}
.card__meta-text--added {
inline-size: 100%;
order: 1;
}
.card__meta-text--author {
order: 2;
}
.card__meta-text--updated {
border-block-start: var(--card-border);
margin-block-start: var(--meta-spacer-block);
inline-size: 100%;
margin-block-start: calc(var(--meta-spacer-block) * 0.5);
order: 4;
padding-block-start: var(--meta-spacer-block);
}
.card__meta-text--assignees {
margin-block-start: calc(var(--meta-spacer-block) * 3);
order: 6;
white-space: unset !important;
}
.card__meta-avatars--assignees {
margin-inline: 0;
margin-block-start: var(--meta-spacer-block);
margin-inline: 0 var(--meta-spacer-inline);
margin-block-start: calc(var(--meta-spacer-block) * 3);
order: 5;
.avatar {
display: grid;
}
}
.card__meta-avatars--author {
display: initial;
}
&:has(.card__meta-avatars--assignees .avatar) {
.card__meta-text--assignees {
order: 5;
}
.card__meta-avatars--assignees .avatar {
display: grid;
.card__meta-avatars--assignees {
margin-block-start: var(--meta-spacer-block);
order: 6;
}
}
}
}
&:has(.card__closed) .card__meta {
@media (max-width: 639px) {
.card__meta-avatars--assignees {
display: none;
}
}
}
@@ -252,10 +289,16 @@
@media (max-width: 639px) {
display: grid;
font-size: var(--text-x-small);
gap: 1ch 0;
grid-template-columns: 1fr auto;
grid-template-areas:
"meta bg-zoom"
"meta reactions";
&:not(:has(.reaction)),
&:has(.card__background) {
column-gap: 2ch;
}
}
}
@@ -4,11 +4,12 @@ class Account::EntropiesController < ApplicationController
before_action :ensure_admin
def update
Current.account.entropy.update!(entropy_params)
@account = Current.account
@account.entropy.update!(entropy_params)
respond_to do |format|
format.html { redirect_to account_settings_path, notice: "Account updated" }
format.json { head :no_content }
format.json { render "account/settings/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
@@ -5,7 +5,10 @@ class Account::SettingsController < ApplicationController
before_action :set_account
def show
@users = @account.users.active.alphabetically.includes(:identity)
respond_to do |format|
format.html { @users = @account.users.active.alphabetically.includes(:identity) }
format.json
end
end
def update
@@ -10,7 +10,7 @@ class Boards::EntropiesController < ApplicationController
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
format.json { render "boards/show", status: :ok }
end
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
+1 -1
View File
@@ -7,6 +7,6 @@ module SetPlatform
private
def platform
@platform ||= ApplicationPlatform.new(request.user_agent)
@platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent)
end
end
@@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
clearCache() {
Turbo.offline.clearCache()
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
submit() {
if (this.inputTarget.disabled) return
this.element.submit()
this.element.requestSubmit()
this.inputTarget.disabled = true
}
}
@@ -15,7 +15,7 @@ export default class extends Controller {
}
#showFileName() {
this.fileNameTarget.innerHTML = this.#file.name
this.fileNameTarget.innerText = this.#file.name
this.fileNameTarget.removeAttribute("hidden")
this.placeholderTarget.setAttribute("hidden", true)
}
+1
View File
@@ -1,2 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
+9
View File
@@ -0,0 +1,9 @@
import { Turbo } from "@hotwired/turbo-rails"
if (Current.user) {
Turbo.offline.start("/service-worker.js", {
scope: "/",
native: true,
preload: /\/assets\//
})
}
@@ -13,6 +13,7 @@ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfe
data = load(file)
data.slice(*attributes).merge(
"account_id" => account.id,
"key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH),
"service_name" => ::ActiveStorage::Blob.service.name
)
end
@@ -22,9 +22,12 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
def import_batch(files)
files.each do |file|
key = File.basename(file)
blob = ::ActiveStorage::Blob.find_by(key: key, account: account)
next unless blob
old_key = file.delete_prefix("storage/")
blob_id = old_key_to_blob_id[old_key]
raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id
blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account)
raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob
zip.read(file) do |stream|
blob.upload(stream)
@@ -32,12 +35,31 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe
end
end
def check_record(file_path)
key = File.basename(file_path)
def old_key_to_blob_id
@old_key_to_blob_id ||= build_old_key_to_blob_id
end
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account)
# File exists without corresponding blob record - could be orphaned or blob not yet imported
# We allow this since blob metadata is imported before files
def build_old_key_to_blob_id
zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map|
data = load(file)
old_key = data["key"]
if map.key?(old_key)
raise IntegrityError, "Duplicate blob key in export: #{old_key}"
end
map[old_key] = data["id"]
end
end
def with_zip(zip)
@old_key_to_blob_id = nil
super
end
def check_record(file_path)
old_key = file_path.delete_prefix("storage/")
unless old_key_to_blob_id.key?(old_key)
raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export"
end
end
end
+35 -7
View File
@@ -26,20 +26,42 @@ class Account::DataTransfer::Manifest
[
Account::DataTransfer::AccountRecordSet.new(account),
Account::DataTransfer::UserRecordSet.new(account),
*build_record_sets(::User::Settings, ::Tag, ::Board, ::Column),
*build_record_sets(
::User::Settings,
::Tag,
::Board,
::Column
),
Account::DataTransfer::EntropyRecordSet.new(account),
*build_record_sets(
::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step,
::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow,
::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention,
::Filter, ::Webhook::DelinquencyTracker, ::Event,
::Notification, ::Notification::Bundle, ::Webhook::Delivery
::Board::Publication,
::Webhook,
::Access,
::Card,
::Comment,
::Step,
::Assignment,
::Tagging,
::Closure,
::Card::Goldness,
::Card::NotNow,
::Card::ActivitySpike,
::Watch,
::Pin,
::Reaction,
::Mention,
::Filter,
::Webhook::DelinquencyTracker,
::Event,
::Notification,
::Notification::Bundle,
::Webhook::Delivery
),
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),
*build_record_sets(::ActiveStorage::Attachment),
Account::DataTransfer::ActionText::RichTextRecordSet.new(account),
Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)
]
].then { set_importable_model_names(it) }
end
def build_record_sets(*models)
@@ -47,4 +69,10 @@ class Account::DataTransfer::Manifest
Account::DataTransfer::RecordSet.new(account: account, model: model)
end
end
def set_importable_model_names(record_sets)
model_names = record_sets.filter_map { |record_set| record_set.model&.name }
record_sets.each { |record_set| record_set.importable_model_names = model_names }
record_sets
end
end
+12 -2
View File
@@ -4,12 +4,14 @@ class Account::DataTransfer::RecordSet
IMPORT_BATCH_SIZE = 100
attr_accessor :importable_model_names
attr_reader :account, :model, :attributes
def initialize(account:, model:, attributes: nil)
def initialize(account:, model:, attributes: nil, importable_model_names: nil)
@account = account
@model = model
@attributes = (attributes || model.column_names).map(&:to_s)
@importable_model_names = importable_model_names || [ model.name ]
end
def export(to:, start: nil)
@@ -113,7 +115,7 @@ class Account::DataTransfer::RecordSet
def check_association_doesnt_exist(data, association, associated_id)
if association.polymorphic?
type_column = association.foreign_type.to_s
associated_class = data[type_column].constantize
associated_class = verify_model_type(data[type_column])
else
associated_class = association.klass
end
@@ -123,6 +125,14 @@ class Account::DataTransfer::RecordSet
end
end
def verify_model_type(type_name)
if importable_model_names.include?(type_name)
type_name.constantize
else
raise IntegrityError, "Unrecognized model type: #{type_name}"
end
end
def skip_to(file_list, last_id)
index = file_list.index(last_id)
+1 -1
View File
@@ -20,6 +20,6 @@ module User::Named
def familiar_name
names = name.split
return name if names.length <= 1
"#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}"
"#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
+2 -1
View File
@@ -84,7 +84,8 @@ class ZipFile
def read_from_s3(blob)
url = blob.url(expires_in: 6.hour)
remote_io = RemoteIO.new(url)
ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer
remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer)
reader = Reader.new(remote_io)
yield reader
end
+6 -2
View File
@@ -1,4 +1,9 @@
class ZipFile::RemoteIO < ZipKit::RemoteIO
def initialize(url, ssl_verify_peer: true)
super(url)
@ssl_verify_peer = ssl_verify_peer
end
protected
def request_range(range)
with_http do |http|
@@ -37,8 +42,7 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO
def with_http
http = Net::HTTP.new(@uri.hostname, @uri.port)
http.use_ssl = @uri.scheme == "https"
# FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer
http.start { yield http }
end
end
-2
View File
@@ -21,5 +21,3 @@
<%= render "account/settings/cancellation" %>
</div>
</div>
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
@@ -1 +1,3 @@
json.(Current.account, :name)
json.(@account, :id, :name, :cards_count)
json.created_at @account.created_at.utc
json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days
+1
View File
@@ -1,6 +1,7 @@
json.cache! board do
json.(board, :id, :name, :all_access)
json.created_at board.created_at.utc
json.auto_postpone_period_in_days board.auto_postpone_period_in_days
json.url board_url(board)
json.creator board.creator, partial: "users/user", as: :user
+1 -1
View File
@@ -12,7 +12,7 @@
</div>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if page.used? %>
<%= with_automatic_pagination "maybe", @page do %>
<%= with_automatic_pagination "maybe", page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<% end %>
@@ -3,7 +3,7 @@
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -3,7 +3,7 @@
<% if @card.column %>
<%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %>
<% elsif @card.awaiting_triage? %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -13,7 +13,5 @@
<span>Create and add another</span>
<% end %>
</div>
<%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %>
</div>
+1 -5
View File
@@ -26,9 +26,5 @@
</div>
<% end %>
<% if Fizzy.saas? %>
<%= render "cards/container/footer/saas/create", card: card %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
<%= render "cards/container/footer/create", card: card %>
</section>
@@ -3,7 +3,7 @@
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
-1
View File
@@ -17,7 +17,6 @@
<%= render "layouts/theme_preference" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %>
<%= javascript_importmap_tags %>
<%= tenanted_action_cable_meta_tag %>
+1 -1
View File
@@ -6,7 +6,7 @@
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
<%= icon_tag "logout", class: "popup__icon" %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out</span>
<% end %>
<% end %>
-41
View File
@@ -1,41 +0,0 @@
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
if (event.request.destination === 'document') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.catch(() => caches.match(event.request))
)
}
})
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+97
View File
@@ -0,0 +1,97 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
// Documents - top-level navigation requests only
// We need `cache: "no-cache"` here to work around
// an annoying Safari PWA bug that predates offline mode
TurboOffline.addRule({
match: (request) => request.destination === "document",
except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 3,
fetchOptions: { cache: "no-cache" },
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
handler: TurboOffline.handlers.cacheFirst({
cacheName: "assets",
maxAge: 60 * 60 * 24 * 7,
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/(boards|cards|users)\//,
except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 2,
maxEntrySize: 1024 * 1024
})
})
TurboOffline.addRule({
match: /\/rails\/active_storage\//,
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
maxEntries: 500,
fetchOptions: { mode: "cors" }
})
})
// Everything else
TurboOffline.addRule({
except: /\/(service-worker\.js|edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
networkTimeout: 3,
maxEntrySize: 1024 * 1024
})
})
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
TurboOffline.start()
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
async function showNotification({ title, options }) {
return self.registration.showNotification(title, options)
}
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
async function openURL(url) {
const clients = await self.clients.matchAll({ type: "window" })
const focused = clients.find((client) => client.focused)
if (focused) {
await focused.navigate(url)
} else {
await self.clients.openWindow(url)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
</header>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do |form| %>
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase",
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
+1 -1
View File
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out of Fizzy on this device</span>
<% end %>
<% end %>
+1
View File
@@ -2,6 +2,7 @@
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/turbo/offline", to: "turbo-offline.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js"
+75 -3
View File
@@ -434,6 +434,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -460,6 +461,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -484,7 +486,7 @@ Creates a new Board in the account.
|-----------|------|----------|-------------|
| `name` | string | Yes | The name of the board |
| `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` |
| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |
| `public_description` | string | No | Rich text description shown on the public board page |
__Request:__
@@ -514,7 +516,7 @@ Updates a Board. Only board administrators can update a board.
|-----------|------|----------|-------------|
| `name` | string | No | The name of the board |
| `all_access` | boolean | No | Whether any user in the account can access this board |
| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed |
| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) |
| `public_description` | string | No | Rich text description shown on the public board page |
| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) |
@@ -524,7 +526,7 @@ __Request:__
{
"board": {
"name": "Updated board name",
"auto_postpone_period": 14,
"auto_postpone_period_in_days": 30,
"public_description": "This is a **public** description of the board.",
"all_access": false,
"user_ids": [
@@ -567,6 +569,7 @@ HTTP/1.1 201 Created
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -591,6 +594,72 @@ __Response:__
Returns `204 No Content` on success.
## Account
### `GET /account/settings`
Returns the current account.
__Response:__
```json
{
"id": "03f5v9zjvypwh0t0e2rfh0h7k",
"name": "37signals",
"cards_count": 5,
"created_at": "2025-12-05T19:36:35.401Z",
"auto_postpone_period_in_days": 30
}
```
The `auto_postpone_period_in_days` is the account-level default in days (e.g. `30`). Cards are automatically moved to "Not Now" after this period of inactivity. Each board can override this with its own value.
### `PUT /account/entropy`
Updates the account-level default auto close period. Requires admin role.
__Request:__
```json
{
"entropy": {
"auto_postpone_period_in_days": 30
}
}
```
__Response:__
Returns the account object:
```json
{
"id": "03f5v9zjvypwh0t0e2rfh0h7k",
"name": "37signals",
"cards_count": 5,
"created_at": "2025-12-05T19:36:35.401Z",
"auto_postpone_period_in_days": 30
}
```
### `PUT /:account_slug/boards/:board_id/entropy`
Updates the auto close period for a specific board. Requires board admin permission.
__Request:__
```json
{
"board": {
"auto_postpone_period_in_days": 90
}
}
```
__Response:__
Returns the board object.
## Webhooks
Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks.
@@ -757,6 +826,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -810,6 +880,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -1077,6 +1148,7 @@ __Response:__
"name": "Fizzy",
"all_access": true,
"created_at": "2025-12-05T19:36:35.534Z",
"auto_postpone_period_in_days": 30,
"url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm",
"creator": {
"id": "03f5v9zjw7pz8717a4no1h8a7",
@@ -1,70 +0,0 @@
/* 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 {
border-block-start: 1px solid var(--settings-subscription-text-color);
content: "";
display: block;
inline-size: 8ch;
margin: 0 auto 1ch;
}
}
.settings-subscription__link {
color: var(--settings-subscription-text-color);
}
.settings-subscription__notch {
animation: wiggle 500ms ease;
background: var(--settings-subscription-background);
border-radius: 3em;
box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
color: var(--settings-subscription-text-color);
margin-inline: auto;
padding: 0.3em 0.3em 0.3em 1.2em;
transform: rotate(-1deg);
@media (max-width: 640px) {
border-radius: 1ch;
padding-block: 1ch;
padding-inline: 2ch;
}
.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);
}
}
}
@@ -1,19 +0,0 @@
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
@@ -1,12 +0,0 @@
class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController
before_action :ensure_downgradeable
private
def target_plan
Plan.paid
end
def ensure_downgradeable
head :bad_request unless subscription.plan == Plan.paid_with_extra_storage
end
end
@@ -1,32 +0,0 @@
class Account::Subscriptions::UpdatePlanController < ApplicationController
before_action :ensure_admin
def create
portal_session = Stripe::BillingPortal::Session.create(
customer: subscription.stripe_customer_id,
return_url: account_settings_url(anchor: "subscription"),
flow_data: {
type: "subscription_update_confirm",
subscription_update_confirm: {
subscription: subscription.stripe_subscription_id,
items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ]
}
}
)
redirect_to portal_session.url, allow_other_host: true
end
private
def target_plan
raise NotImplementedError
end
def subscription
@subscription ||= Current.account.subscription
end
def stripe_subscription_item_id
Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id
end
end
@@ -1,12 +0,0 @@
class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController
before_action :ensure_upgradeable
private
def target_plan
Plan.paid_with_extra_storage
end
def ensure_upgradeable
head :bad_request unless subscription.plan == Plan.paid
end
end
@@ -1,46 +0,0 @@
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_param.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_param.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 plan_param
@plan_param ||= Plan[params[:plan_key]] || Plan.paid
end
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_param.key, status: "incomplete")
end
end
end
@@ -1,5 +0,0 @@
class Admin::AccountSearchesController < AdminController
def create
redirect_to saas.edit_admin_account_path(params[:q])
end
end
@@ -1,27 +0,0 @@
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(**overridden_limits_params.to_h.symbolize_keys)
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_limits_params
params.expect(account: [ :card_count, :bytes_used ])
end
end
@@ -1,13 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -6,10 +6,6 @@ class Admin::StatsController < AdminController
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
@paid_accounts_total = paid_subscriptions.distinct.count(:account_id)
@paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id)
@paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id)
@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
@@ -21,12 +17,4 @@ class Admin::StatsController < AdminController
@recent_accounts = Account.order(created_at: :desc).limit(10)
end
private
def paid_subscriptions
Account::Subscription
.where(status: %w[active trialing past_due])
.where(plan_key: %w[monthly_v1 monthly_extra_storage_v1])
.where.not(account_id: Account::BillingWaiver.select(:account_id))
end
end
@@ -1,12 +0,0 @@
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
@@ -1,8 +0,0 @@
module Card::Limited
extend ActiveSupport::Concern
private
def ensure_under_limits
head :forbidden if Current.account.exceeding_limits?
end
end
@@ -1,11 +0,0 @@
module Card::LimitedCreation
extend ActiveSupport::Concern
included do
include Card::Limited
# 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_under_limits, only: %i[ create ], if: -> { request.format.json? }
end
end
@@ -1,9 +0,0 @@
module Card::LimitedPublishing
extend ActiveSupport::Concern
included do
include Card::Limited
before_action :ensure_under_limits, only: %i[ create ]
end
end
@@ -1,85 +0,0 @@
class Stripe::WebhooksController < ApplicationController
allow_unauthenticated_access
skip_before_action :require_account
skip_forgery_protection
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),
plan_key: plan_key_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
def plan_key_for(stripe_subscription)
price_id = stripe_subscription.items.data.first&.price&.id
Plan.find_by_price_id(price_id)&.key
end
end
-19
View File
@@ -1,19 +0,0 @@
module SubscriptionsHelper
def storage_to_human_size(bytes)
number_to_human_size(bytes).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 is <b>#{ format_currency(subscription.next_amount_due) }</b> on".html_safe
end
end
def format_currency(amount)
number_to_currency(amount, precision: (amount % 1).zero? ? 0 : 2)
end
end
@@ -1,8 +0,0 @@
class Account::SyncStripeCustomerEmailJob < ApplicationJob
queue_as :default
retry_on Stripe::StripeError, wait: :polynomially_longer
def perform(subscription)
subscription.sync_customer_email_to_stripe
end
end
-50
View File
@@ -1,50 +0,0 @@
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
set_callback :incinerate, :before, -> { subscription&.cancel }
set_callback :cancel, :after, -> { subscription&.pause }
set_callback :reactivate, :before, -> { subscription&.resume }
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
def owner_email_changed
Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription
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
@@ -1,7 +0,0 @@
class Account::BillingWaiver < SaasRecord
belongs_to :account
def subscription
@subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage)
end
end
-56
View File
@@ -1,56 +0,0 @@
module Account::Limited
extend ActiveSupport::Concern
included do
has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy
end
NEAR_CARD_LIMIT_THRESHOLD = 100
NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes
def override_limits(card_count: nil, bytes_used: nil)
(overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:)
end
def billed_cards_count
overridden_limits&.card_count || cards_count
end
def billed_bytes_used
overridden_limits&.bytes_used || bytes_used
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 nearing_plan_storage_limit?
remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD
end
def exceeding_storage_limit?
billed_bytes_used > plan.storage_limit
end
def exceeding_limits?
exceeding_card_limit? || exceeding_storage_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
def remaining_storage
plan.storage_limit - billed_bytes_used
end
end
@@ -1,4 +0,0 @@
# To ease testing of limits
class Account::OverriddenLimits < SaasRecord
belongs_to :account
end
-67
View File
@@ -1,67 +0,0 @@
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 plan=(plan)
self.plan_key = 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
def pause
if stripe_subscription_id.present?
Stripe::Subscription.update(
stripe_subscription_id,
pause_collection: { behavior: "void" }
)
end
end
def resume
if stripe_subscription_id.present?
Stripe::Subscription.update(
stripe_subscription_id,
pause_collection: ""
)
end
end
def cancel
Stripe::Subscription.cancel(stripe_subscription_id) if stripe_subscription_id.present?
rescue Stripe::InvalidRequestError => e
# Subscription already deleted/canceled in Stripe - treat as success
Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
end
def sync_customer_email_to_stripe
if stripe_customer_id && (email = owner_email)
Stripe::Customer.update(stripe_customer_id, email: email)
end
rescue Stripe::InvalidRequestError => e
# Customer already deleted in Stripe - treat as success
Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}"
end
private
# Account owner email for Stripe customer record. Returns nil when:
# - No owner exists (ownership being transferred, account in limbo)
# - Owner has no identity (deactivated user)
def owner_email
account.users.owner.first&.identity&.email_address
end
end
-59
View File
@@ -1,59 +0,0 @@
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"] },
monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_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 paid_with_extra_storage
@paid_with_extra_storage ||= find(:monthly_extra_storage_v1)
end
def find(key)
@all_by_key ||= all.index_by(&:key).with_indifferent_access
@all_by_key[key]
end
def find_by_price_id(price_id)
all.find { |plan| plan.stripe_price_id == price_id }
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.infinite?
end
end
@@ -1,23 +0,0 @@
module User::NotifiesAccountOfEmailChange
extend ActiveSupport::Concern
included do
after_update :notify_account_of_owner_change, if: :account_owner_changed?
end
private
# Account owner changed when:
# - The current owner changed their email
# - A user just became the owner (ownership transfer)
def account_owner_changed?
owner? && identity && (saved_change_to_identity_id? || became_owner?)
end
def became_owner?
saved_change_to_role? && role_before_last_save != "owner"
end
def notify_account_of_owner_change
account.owner_email_changed
end
end
@@ -1,18 +0,0 @@
<h3 class="margin-block-start txt-large font-weight-black txt-tight-lines">
<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards and all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
<% elsif Current.account.exceeding_card_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
<% elsif Current.account.exceeding_storage_limit? %>
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
<% else %>
Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
<% end %>
</h3>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= format_currency(Plan.paid.price) %>/month</strong> for <strong class="txt-nowrap">unlimited cards</strong>, <strong class="txt-nowrap">unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ format_currency(Plan.paid.price) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<p>Cancel anytime, no contracts, take your data with you whenever.</p>
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= number_with_delimiter(Plan.free.card_limit) %> cards, unlimited users, and <%= storage_to_human_size(Plan.free.storage_limit) %> of storage.</p>
@@ -1,21 +0,0 @@
<h3 class="margin-block-start txt-x-large font-weight-black txt-tight-lines">
<% if Current.account.exceeding_storage_limit? %>
Youve run out of storage
<% elsif Current.account.nearing_plan_storage_limit? %>
Youve used <strong><%= storage_to_human_size(Current.account.billed_bytes_used) %></strong> of storage
<% else %>
Thank you for buying Fizzy
<% end %>
</h3>
<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>
<p class="margin-block-start-half">
The <strong><%= Current.account.plan.name %></strong> plan includes <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong>. Upgrade to get <strong><%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %></strong> extra storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
</p>
<%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<% end %>
<% if Current.account.subscription %>
<%= render "account/settings/subscription", subscription: Current.account.subscription %>
<% end %>
@@ -1,20 +0,0 @@
<% if subscription.current_period_end %>
<p class="margin-block-start-half"><%= subscription_period_end_action(subscription) %> <strong><%= subscription.current_period_end.to_date.to_fs(:long) %></strong></p>
<% end %>
<p>
<%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>
</p>
<div class="settings-subscription__footer txt-small margin-block">
Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= storage_to_human_size(Current.account.plan.storage_limit) %> of storage.
<% if subscription.plan == Plan.paid && !(Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit?) %>
<%= button_to "Upgrade", account_subscription_upgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
<% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %>
<%= button_to "Downgrade", account_subscription_downgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid.storage_limit) %> storage.
<% end %>
</div>
@@ -1,11 +0,0 @@
<% if Current.user.admin? && !Current.account.comped? %>
<section class="panel panel--wide settings-subscription__panel shadow center margin-block-double hide-on-native">
<h2 id="subscription" class="divider settings-subscription__divider txt-small txt-uppercase margin-none">Subscription</h2>
<% if Current.account.plan.free? %>
<%= render "account/settings/free_plan" %>
<% else %>
<%= render "account/settings/paid_plan" %>
<% end %>
</section>
<% end %>
@@ -1,9 +0,0 @@
<div class="settings-subscription__notch card-perma__notch card-perma__notch--bottom">
<% if Current.user.admin? %>
<%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
<% else %>
<%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %>
<%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %>
<% end %>
</div>
@@ -1,4 +0,0 @@
<strong>Youve used your <u><%= Plan.free.card_limit %></u> free cards.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to Unlimited</span>
<% end %>
@@ -1,4 +0,0 @@
<strong>Youve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>.</strong>
<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
<span>Upgrade to get more</span>
<% end %>
@@ -1,3 +0,0 @@
<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>
@@ -1,3 +0,0 @@
<div class="pad-inline pad-block-half">
<strong>This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.</strong>
</div>
@@ -1,9 +0,0 @@
<% @page_title = "Thank you" %>
<% if @stripe_session&.payment_status == "paid" %>
<section class="panel panel--wide panel--centered center borderless">
<h1 class="txt-x-large font-weight-black margin-none">Thanks for buying Fizzy!</h1>
<p class="txt-medium margin-none-block-start">Your payment was successful. Youre now on the <strong><%= Current.account.plan.name %></strong> plan.</p>
<%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
</section>
<% end %>
@@ -1,63 +0,0 @@
<% @page_title = "Account admin" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Accounts", saas.admin_accounts_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>
<div class="panel panel--wide shadow center">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-none">Edit Account <%= @account.external_account_id %></h1>
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
<table class="txt-align-start">
<tr>
<td class="txt-subtle">Account Name:</td>
<td class="margin-inline-start-none"><%= @account.name %></td>
</tr>
<tr>
<td class="txt-subtle">Subscription plan:</td>
<td class="margin-inline-start-none"><%= @account.plan.name %></td>
</tr>
<tr>
<td class="txt-subtle">Actual card count:</td>
<td class="margin-inline-start-none"><%= @account.cards_count %> cards </td>
</tr>
<tr>
<td class="txt-subtle">Actual bytes used:</td>
<td class="margin-inline-start-none"><%= storage_to_human_size(@account.bytes_used) %></td>
</tr>
<tr>
<td>
<%= form.label :card_count, class: "txt-subtle" do %>
<span>Override card count</span><br><%= "(Exceeds plan limit)" if @account.exceeding_card_limit? %>
<% end %>
</td>
<td><%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %></td>
</tr>
<tr>
<td>
<%= form.label :bytes_used, class: "txt-subtle" do %>
<span>Override bytes used</span><br><%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %>
<% end %>
</td>
<td><%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %></td>
</tr>
</table>
<button type="submit" class="btn btn--link full-width">Save changes</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 full-width" %>
<% end %>
<% if @account.comped? %>
<%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--plain txt-negative txt-underline" %>
<% else %>
<%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--plain txt-positive txt-underline" %>
<% end %>
</div>
</div>
@@ -1,13 +0,0 @@
<div class="panel panel--centered">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-none">Find an account</h1>
<%= form_with url: saas.admin_account_search_path, class: "flex flex-column gap" do |form| %>
<%= form.text_field :q, placeholder: "Account ID", autofocus: true, class: "input" %>
<%= form.submit "Search", class: "btn btn--link" %>
<% end %>
</div>
</div>
-26
View File
@@ -33,32 +33,6 @@
</div>
</div>
<div class="flex flex-column gap-half">
<header>
<h2 class="divider txt-medium margin-block-start">Accounts Subscribed</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><%= @paid_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><%= @paid_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><%= @paid_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>
@@ -1,5 +0,0 @@
<% if Current.account.exceeding_limits? %>
<%= render "account/subscriptions/upgrade" %>
<% else %>
<%= render "cards/container/footer/create", card: card %>
<% end %>
@@ -1,9 +0,0 @@
<div class="settings_subscription__warning full-width pad-inline center margin-block-half">
<% if Current.user.admin? %>
<%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
<%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
<% else %>
<%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %>
<%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %>
<% end %>
</div>
@@ -1 +0,0 @@
Youve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <strong><%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %></strong>.
@@ -1 +0,0 @@
Youve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <strong><%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %></strong>.
@@ -1 +0,0 @@
This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon.
@@ -1 +0,0 @@
This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon.
-5
View File
@@ -8,10 +8,5 @@ Fizzy::Saas::Engine.routes.draw do
namespace :admin do
mount Audits1984::Engine, at: "/console"
get "stats", to: "stats#show"
resource :account_search, only: :create
resources :accounts do
resource :overridden_limits, only: :destroy
resource :billing_waiver, only: [ :create, :destroy ]
end
end
end
@@ -0,0 +1,7 @@
class DropBillingTables < ActiveRecord::Migration[8.2]
def change
drop_table :account_subscriptions
drop_table :account_overridden_limits
drop_table :account_billing_waivers
end
end
+1 -33
View File
@@ -10,39 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) 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.bigint "bytes_used"
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
ActiveRecord::Schema[8.2].define(version: 2026_03_17_000000) do
create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
-83
View File
@@ -1,83 +0,0 @@
#!/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 23QPQDKZC5BKBIIG7UGT5GR5RM \
--from "Deploy/Fizzy" \
"Development/STRIPE_SECRET_KEY" \
"Development/STRIPE_MONTHLY_V1_PRICE_ID" \
"Development/STRIPE_MONTHLY_EXTRA_STORAGE_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"]
stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_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_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_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}"
-31
View File
@@ -16,10 +16,6 @@ module Fizzy
connects_to database: { writing: :saas, reading: :saas }
end
initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
end
initializer "fizzy_saas.assets" do |app|
app.config.assets.paths << root.join("app/assets/stylesheets")
end
@@ -33,25 +29,6 @@ module Fizzy
app.paths["config/push"].unshift(root.join("config/push.yml").to_s)
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 do
scope module: :subscriptions do
resource :upgrade, only: :create
resource :downgrade, only: :create
end
end
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"
@@ -98,10 +75,6 @@ module Fizzy
end
end
initializer "fizzy_saas.stripe" do
Stripe.api_key = ENV["STRIPE_SECRET_KEY"]
end
initializer "fizzy_saas.sentry" do
if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank?
Sentry.init do |config|
@@ -160,14 +133,10 @@ module Fizzy
end
config.to_prepare do
::Account.include Account::Billing, Account::Limited
::User.include User::NotifiesAccountOfEmailChange
::Identity.include Authorization::Identity, Identity::Devices
::Session.include Session::Devices
::Signup.prepend Signup
ApplicationController.include Authorization::Controller
CardsController.include(Card::LimitedCreation)
Cards::PublishesController.include(Card::LimitedPublishing)
Notification.register_push_target(:native)
@@ -1,29 +0,0 @@
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
@@ -1,73 +0,0 @@
require "test_helper"
class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :mike
end
# Nearing limits - shown in card creation footer
test "admin sees nearing card limit notice" do
accounts(:initech).update_column(:cards_count, 950)
get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /upgrade to unlimited/i, response.body
end
test "admin sees nearing storage limit notice" do
Account.any_instance.stubs(:bytes_used).returns(600.megabytes)
get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /upgrade to get more/i, response.body
end
# Exceeding limits - shown instead of create buttons
test "admin sees exceeding card limit notice" do
accounts(:initech).update_column(:cards_count, 1001)
get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /youve used your.*free cards/i, response.body
end
test "admin sees exceeding storage limit notice" do
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_match /youve run out of.*free storage/i, response.body
end
# Paid accounts under limits - no notices
test "paid account under limits sees no notices" do
logout_and_sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get card_path(cards(:layout), script_name: accounts(:"37s").slug)
assert_response :success
assert_no_match /upgrade/i, response.body
assert_no_match /youve used your/i, response.body
end
# Comped accounts under limits - no notices
test "comped account under limits sees no notices" do
accounts(:initech).comp
get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :success
assert_no_match /upgrade/i, response.body
assert_no_match /youve used your/i, response.body
end
end
@@ -1,35 +0,0 @@
require "test_helper"
require "ostruct"
class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage)
end
test "downgrade redirects to stripe billing portal" do
stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
post account_subscription_downgrade_path
assert_redirected_to "https://billing.stripe.com/session/abc123"
end
test "downgrade requires admin" do
logout_and_sign_in_as :david
post account_subscription_downgrade_path
assert_response :forbidden
end
test "downgrade requires downgradeable plan" do
accounts(:"37s").subscription.update!(plan: Plan.paid)
post account_subscription_downgrade_path
assert_response :bad_request
end
end
@@ -1,62 +0,0 @@
require "test_helper"
class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest
test "free users see current usage" do
sign_in_as :mike
accounts(:initech).update_column(:cards_count, 3)
get account_settings_path(script_name: accounts(:initech).slug)
assert_response :success
assert_match /Youve used.*3.*free cards out of 1,000/i, response.body
end
test "paid users see thank you message" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "h3", text: "Thank you for buying Fizzy"
end
test "regular plan users see upgrade option" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "button", text: /upgrade/i
assert_select "button", text: /downgrade/i, count: 0
end
test "extra storage plan users see downgrade option" do
sign_in_as :kevin
accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active)
get account_settings_path(script_name: accounts(:"37s").slug)
assert_response :success
assert_select "button", text: /downgrade/i
end
test "comped accounts see no subscription panel" do
sign_in_as :mike
accounts(:initech).comp
get account_settings_path(script_name: accounts(:initech).slug)
assert_response :success
assert_no_match /thank you for buying/i, response.body
assert_no_match /free cards out of/i, response.body
assert_no_match /upgrade/i, response.body
assert_no_match /downgrade/i, response.body
end
end
@@ -1,35 +0,0 @@
require "test_helper"
require "ostruct"
class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid)
end
test "upgrade redirects to stripe billing portal" do
stripe_subscription = OpenStruct.new(items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]))
portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123")
Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription)
Stripe::BillingPortal::Session.stubs(:create).returns(portal_session)
post account_subscription_upgrade_path
assert_redirected_to "https://billing.stripe.com/session/abc123"
end
test "upgrade requires admin" do
logout_and_sign_in_as :david
post account_subscription_upgrade_path
assert_response :forbidden
end
test "upgrade requires upgradeable plan" do
accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage)
post account_subscription_upgrade_path
assert_response :bad_request
end
end
@@ -1,60 +0,0 @@
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
test "create with custom plan_key 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).with do |params|
params[:metadata][:plan_key] == :monthly_extra_storage_v1
end.returns(session)
post account_subscription_path(plan_key: :monthly_extra_storage_v1)
assert_redirected_to "https://checkout.stripe.com/session123"
end
end
@@ -1,54 +0,0 @@
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: { 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
@@ -1,32 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,55 +0,0 @@
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
test "cannot create cards via JSON when storage limit exceeded" do
sign_in_as :mike
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
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
end
@@ -1,25 +0,0 @@
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
test "cannot publish cards when storage limit exceeded" do
sign_in_as :mike
Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
assert_response :forbidden
assert cards(:unfinished_thoughts).reload.drafted?
end
end
@@ -1,100 +0,0 @@
require "test_helper"
require "ostruct"
class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
setup do
@account = Account.create!(name: "Test")
@subscription = @account.create_subscription! \
plan_key: "monthly_v1",
status: "incomplete",
stripe_customer_id: "cus_test123"
end
test "invalid signature returns bad request" do
Stripe::Webhook.stubs(:construct_event).raises(Stripe::SignatureVerificationError.new("invalid", "sig"))
post stripe_webhooks_path
assert_response :bad_request
end
test "checkout session completed activates subscription" do
stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i))
event = stripe_event("checkout.session.completed",
mode: "subscription",
customer: "cus_test123",
subscription: "sub_123",
metadata: { "plan_key" => "monthly_v1" }
)
Stripe::Webhook.stubs(:construct_event).returns(event)
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
post stripe_webhooks_path
assert_response :ok
@subscription.reload
assert_equal "sub_123", @subscription.stripe_subscription_id
assert_equal "active", @subscription.status
end
test "subscription updated changes status and syncs next amount due" do
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
stripe_sub = OpenStruct.new(
id: "sub_123",
customer: "cus_test123",
status: "past_due",
cancel_at: nil,
items: stub_items(1.month.from_now.to_i)
)
event = stripe_event("customer.subscription.updated", id: "sub_123")
Stripe::Webhook.stubs(:construct_event).returns(event)
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
post stripe_webhooks_path
assert_response :ok
@subscription.reload
assert_equal "past_due", @subscription.status
assert_equal 1999, @subscription.next_amount_due_in_cents
end
test "subscription deleted cancels subscription" do
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
stripe_sub = OpenStruct.new(
id: "sub_123",
customer: "cus_test123",
status: "canceled",
cancel_at: nil,
items: stub_items(1.month.from_now.to_i)
)
event = stripe_event("customer.subscription.deleted", id: "sub_123")
Stripe::Webhook.stubs(:construct_event).returns(event)
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
post stripe_webhooks_path
assert_response :ok
@subscription.reload
assert_equal "canceled", @subscription.status
assert_nil @subscription.stripe_subscription_id
assert_nil @subscription.next_amount_due_in_cents
end
private
def stripe_event(type, **attributes)
OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes)))
end
def stub_items(current_period_end)
OpenStruct.new(data: [ OpenStruct.new(current_period_end: current_period_end) ])
end
end
-11
View File
@@ -1,11 +0,0 @@
_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 %>

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