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:
@@ -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
@@ -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!
|
||||
|
||||
@@ -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
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
import "initializers/current"
|
||||
import "initializers/bridge/bridge_element"
|
||||
import "initializers/offline"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }) %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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? %>
|
||||
You’ve 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? %>
|
||||
You’ve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
|
||||
<% elsif Current.account.exceeding_storage_limit? %>
|
||||
You’ve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
|
||||
<% else %>
|
||||
You’ve 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? %> it’s 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 you’re 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? %>
|
||||
You’ve run out of storage
|
||||
<% elsif Current.account.nearing_plan_storage_limit? %>
|
||||
You’ve 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 you’re 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>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 %>
|
||||
@@ -1,4 +0,0 @@
|
||||
<strong>You’ve 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. 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 %>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
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>.
|
||||
-1
@@ -1 +0,0 @@
|
||||
You’ve 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
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
@@ -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 /you’ve 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 /you’ve 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 /you’ve 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 /you’ve 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 /You’ve 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
@@ -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
Reference in New Issue
Block a user