From 5d084a46ce58217bde19302dd1044a693c766177 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 2 Jan 2026 19:25:47 +0100
Subject: [PATCH 01/33] Switch to turbo-rails version with offline mode support
---
Gemfile | 2 +-
Gemfile.lock | 14 ++++++++++----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/Gemfile b/Gemfile
index 17e7496c0..da66e1473 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
index 3f8461b39..3a35c35c7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -11,6 +11,15 @@ GIT
specs:
useragent (0.16.11)
+GIT
+ remote: https://github.com/hotwired/turbo-rails.git
+ revision: e3795ef6aac6a3d18039751febe6a301cfe71a7b
+ branch: offline-cache
+ specs:
+ turbo-rails (2.0.20)
+ 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!
From ada4688c4e0bc2a6adcdd757821d1779ed4cd570 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Mon, 5 Jan 2026 12:10:57 +0100
Subject: [PATCH 02/33] Take a first stab at offline mode support
---
Gemfile.lock | 4 +-
Gemfile.saas.lock | 14 +++--
app/javascript/initializers/index.js | 1 +
app/javascript/initializers/offline.js | 6 ++
app/views/pwa/service_worker.js | 41 --------------
app/views/pwa/service_worker.js.erb | 76 ++++++++++++++++++++++++++
config/importmap.rb | 1 +
7 files changed, 96 insertions(+), 47 deletions(-)
create mode 100644 app/javascript/initializers/offline.js
delete mode 100644 app/views/pwa/service_worker.js
create mode 100644 app/views/pwa/service_worker.js.erb
diff --git a/Gemfile.lock b/Gemfile.lock
index 3a35c35c7..a710d0f9e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,10 +13,10 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: e3795ef6aac6a3d18039751febe6a301cfe71a7b
+ revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
branch: offline-cache
specs:
- turbo-rails (2.0.20)
+ turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 152d1889c..cec5be07b 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -60,6 +60,15 @@ GIT
rails (>= 6.1)
yabeda (~> 0.6)
+GIT
+ remote: https://github.com/hotwired/turbo-rails.git
+ revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
+ branch: offline-cache
+ specs:
+ turbo-rails (2.0.21)
+ actionpack (>= 7.1.0)
+ railties (>= 7.1.0)
+
GIT
remote: https://github.com/rails/rails.git
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
@@ -633,9 +642,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)
@@ -752,7 +758,7 @@ DEPENDENCIES
stripe (~> 18.0)
thruster
trilogy (~> 2.10)
- turbo-rails
+ turbo-rails!
useragent!
vcr
web-console!
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 90ef36a26..1d16ef205 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,2 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
+import "initializers/offline"
diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js
new file mode 100644
index 000000000..c5eff824e
--- /dev/null
+++ b/app/javascript/initializers/offline.js
@@ -0,0 +1,6 @@
+import { Turbo } from "@hotwired/turbo-rails"
+
+Turbo.offline.start("/service-worker.js", {
+ scope: "/",
+ native: true
+})
diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js
deleted file mode 100644
index 91903e735..000000000
--- a/app/views/pwa/service_worker.js
+++ /dev/null
@@ -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)
- }
-}
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
new file mode 100644
index 000000000..fb583658c
--- /dev/null
+++ b/app/views/pwa/service_worker.js.erb
@@ -0,0 +1,76 @@
+importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
+
+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
+ })
+})
+
+TurboOffline.addRule({
+ match: (request) => {
+ const url = request.url
+ return /\/(boards|cards|users)\//.test(url) && !/\/(edit|pin|watch|new)$/.test(url)
+ },
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "cards",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 3
+ })
+})
+
+TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 3
+ })
+})
+
+// Everything else
+TurboOffline.addRule({
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "misc",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3
+ })
+})
+
+TurboOffline.start()
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+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)
+ }
+}
diff --git a/config/importmap.rb b/config/importmap.rb
index 5da84683c..f6910c3e1 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -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"
From 9fc724572e900953ca500178e3615ec19b7e8602 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 20 Jan 2026 10:53:40 +0100
Subject: [PATCH 03/33] Use new `except` option for offline rules
---
app/views/pwa/service_worker.js.erb | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index fb583658c..0c4ab6ea5 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -9,14 +9,12 @@ TurboOffline.addRule({
})
TurboOffline.addRule({
- match: (request) => {
- const url = request.url
- return /\/(boards|cards|users)\//.test(url) && !/\/(edit|pin|watch|new)$/.test(url)
- },
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "cards",
maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 3
+ networkTimeout: 2
})
})
@@ -25,7 +23,7 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 3
+ networkTimeout: 2
})
})
From 975968b50f9c8033e278460736e82bbfc2b3dec2 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 20 Jan 2026 19:16:17 +0100
Subject: [PATCH 04/33] Only enable offline caching for Hotwire Native apps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This change conditionally renders the TurboOffline caching rules based on
whether the request comes from a Hotwire Native app. Web browsers get a
minimal service worker that only handles push notifications and a simple
document fetch fallback.
Why separate behavior for native vs web?
-----------------------------------------
We want offline caching for Hotwire Native apps (where users expect
app-like offline behavior) but not for regular web browsers.
Why use ERB conditional rendering?
----------------------------------
We explored several approaches to detect Hotwire Native requests in the
service worker:
1. User-Agent detection in fetch handler: Would be ideal, but Android
WebViews override the User-Agent header with the system default when
requests are intercepted by service workers, stripping the custom
"Hotwire Native" identifier.
2. Custom header (X-Hotwire-Native) from native apps: Would require
intercepting ALL requests at the native level using both
WebViewClient.shouldInterceptRequest() and ServiceWorkerClient.
Complex to implement and still incomplete coverage for all request
types (navigation, resources loaded by HTML).
3. In-memory flag with IndexedDB persistence: Service workers can be
terminated when idle and restart with fresh state. Reading from
IndexedDB is async, but the decision to call respondWith() in a
fetch handler must be synchronous.
4. Separate service worker URLs: Same theoretical churn problem as ERB
rendering, with more complexity.
Why ERB conditional rendering works in practice
-----------------------------------------------
The main concern with conditional ERB rendering was "churn" — the service
worker constantly updating as different client types fetch it. However,
this only happens when web and native share storage on the same device.
In practice, this is rare because:
- Android native apps use isolated WebView storage
- iOS doesn't support service workers in WebViews (yet)
- Web browsers have completely separate storage
So each context gets its own stable service worker without churn.
Co-Authored-By: Claude Opus 4.5
---
app/views/pwa/service_worker.js.erb | 73 +++++++++++++++++------------
1 file changed, 43 insertions(+), 30 deletions(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 0c4ab6ea5..b3f59aa60 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -1,44 +1,57 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
-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
+<% if hotwire_native_app? %>
+ 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
+ })
})
-})
-TurboOffline.addRule({
- match: /\/(boards|cards|users)\//,
- except: /\/(edit|pin|watch|new)$/,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "cards",
- maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 2
+ TurboOffline.addRule({
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "cards",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 2
+ })
})
-})
-TurboOffline.addRule({
- match: /\/rails\/active_storage\//,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "storage",
- maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 2
+ TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 2
+ })
})
-})
-// Everything else
-TurboOffline.addRule({
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "misc",
- maxAge: 60 * 60 * 24,
- networkTimeout: 3
+ // Everything else
+ TurboOffline.addRule({
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "misc",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3
+ })
})
-})
-TurboOffline.start()
+ TurboOffline.start()
+<% else %>
+ self.addEventListener("fetch", (event) => {
+ if (event.request.method !== "GET") return
-self.addEventListener('activate', (event) => {
+ if (event.request.destination === "document") {
+ event.respondWith(
+ fetch(event.request, { cache: "no-cache" })
+ .catch(() => caches.match(event.request))
+ )
+ }
+ })
+<% end %>
+
+self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
From a949f1e3aed45b89e8a8a9caa6f486c858f87acf Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 27 Jan 2026 09:32:15 +0100
Subject: [PATCH 05/33] Clear offline cache when logging out
Adds a logout Stimulus controller that sends a message to the service
worker to clear all cached content when the user logs out. This ensures
that cached data from one user isn't accessible after logout.
The implementation:
- Adds logout_controller.js that posts { action: "clearCache" } to the
service worker via postMessage
- Updates logout buttons to use the controller on form submission
Also fixes data-turbo placement: moved from button to form element where
it actually takes effect for disabling Turbo Drive form submissions.
Co-Authored-By: Claude Opus 4.5
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/javascript/controllers/logout_controller.js | 8 ++++++++
app/views/my/menus/_settings.html.erb | 2 +-
app/views/users/show.html.erb | 2 +-
5 files changed, 12 insertions(+), 4 deletions(-)
create mode 100644 app/javascript/controllers/logout_controller.js
diff --git a/Gemfile.lock b/Gemfile.lock
index a710d0f9e..a17769c07 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
+ revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index cec5be07b..03c8e9141 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 49be3f2d43edeeedad48cb492b35242fdb27af74
+ revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/logout_controller.js
new file mode 100644
index 000000000..bc0511a7f
--- /dev/null
+++ b/app/javascript/controllers/logout_controller.js
@@ -0,0 +1,8 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ async clearCache() {
+ const registration = await navigator.serviceWorker?.ready
+ registration?.active?.postMessage({ action: "clearCache" })
+ }
+}
diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb
index d1251b7c0..6b711544b 100644
--- a/app/views/my/menus/_settings.html.erb
+++ b/app/views/my/menus/_settings.html.erb
@@ -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: "logout", action: "submit->logout#clearCache" } } do %>
Sign out
<% end %>
<% end %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 58068d2d6..a285a4f5a 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
- <%= 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: "logout", action: "submit->logout#clearCache" } } do %>
Sign out of Fizzy on this device
<% end %>
<% end %>
From cf8d92afeaf031980296da10340674477c339f4f Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Wed, 28 Jan 2026 21:30:50 +0100
Subject: [PATCH 06/33] Enable offline caching for all web users
Previously, offline caching was conditionally enabled only for Hotwire
Native apps. This removes that restriction and enables offline support
for all users, including PWAs and regular browsers.
This Uses the new `fetchOptions` support in `TurboOffline` handlers
to pass `cache: "no-cache"` for document fetches, which we need
to work around a quite annoying Safari PWA bug
(see https://github.com/basecamp/fizzy/pull/1014).
Also, simplify a bit the cache names and remove the `misc` one.
Co-Authored-By: Claude Opus 4.5
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/views/pwa/service_worker.js.erb | 83 +++++++++++++++--------------
3 files changed, 44 insertions(+), 43 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index a17769c07..3cad12c54 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
+ revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 03c8e9141..3517a420e 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 45a2a40be0a677808a3a269311f1b5abaa3b30ba
+ revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
branch: offline-cache
specs:
turbo-rails (2.0.21)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index b3f59aa60..cfdf5468f 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -1,60 +1,61 @@
importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
-<% if hotwire_native_app? %>
- 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
- })
+// 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",
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 3,
+ fetchOptions: { cache: "no-cache" }
})
+})
- TurboOffline.addRule({
- match: /\/(boards|cards|users)\//,
- except: /\/(edit|pin|watch|new)$/,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "cards",
- maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 2
- })
+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
})
+})
- TurboOffline.addRule({
- match: /\/rails\/active_storage\//,
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "storage",
- maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 2
- })
+TurboOffline.addRule({
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 2
})
+})
- // Everything else
- TurboOffline.addRule({
- handler: TurboOffline.handlers.networkFirst({
- cacheName: "misc",
- maxAge: 60 * 60 * 24,
- networkTimeout: 3
- })
+TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 2
})
+})
- TurboOffline.start()
-<% else %>
- 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))
- )
- }
+// Everything else
+TurboOffline.addRule({
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3
})
-<% end %>
+})
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) ]))
From 90d5a351f0ef3ab712056710f17ff4df65b33ea0 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Thu, 29 Jan 2026 21:03:50 +0100
Subject: [PATCH 07/33] Bump turbo-rails for Range requests support
Cached video and audio files.
---
Gemfile.lock | 4 ++--
Gemfile.saas.lock | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 3cad12c54..405a68bac 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,10 +13,10 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
+ revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
branch: offline-cache
specs:
- turbo-rails (2.0.21)
+ turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 3517a420e..2b9f32fd6 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,10 +62,10 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bcc244043b16ef0202f8fe93aaeb246d841f9eb
+ revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
branch: offline-cache
specs:
- turbo-rails (2.0.21)
+ turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
From 32134be88216b0c7c6e5407171a5be88da6d4253 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Sun, 1 Feb 2026 15:15:34 +0100
Subject: [PATCH 08/33] Bump turbo-rails for network-first strategy fix on
network error
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 405a68bac..aa14545ca 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
+ revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 2b9f32fd6..a4033b12b 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 9bc39c9d72f2f78ebcb30dac645eb1261a1574b3
+ revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
branch: offline-cache
specs:
turbo-rails (2.0.23)
From 9b264483d11123827ccf556e2eedf91601eae9fe Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Thu, 5 Feb 2026 20:23:36 +0100
Subject: [PATCH 09/33] Bump `turbo-rails` for cache size limits
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index aa14545ca..9dd682dce 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
+ revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index a4033b12b..8f5c9dd95 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: cdf9d18a99906c349e01fbe05c6c7cdaa71ce550
+ revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
branch: offline-cache
specs:
turbo-rails (2.0.23)
From b93808235c058c1f2ed422a713f4f97cf09389ab Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Thu, 5 Feb 2026 21:15:22 +0100
Subject: [PATCH 10/33] Limit cached entry sizes
1MB and no limit for number of entries except for attachments, where we
limit individual entries to 2MB and total of entries to 500. The number
is based on the following percentiles for Active Storage blobs:
```
p50: 97.1044921875 KB
p75: 236.9140625 KB
p90: 917.7548828125 KB
```
---
app/views/pwa/service_worker.js.erb | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index cfdf5468f..84f274673 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -9,7 +9,8 @@ TurboOffline.addRule({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
networkTimeout: 3,
- fetchOptions: { cache: "no-cache" }
+ fetchOptions: { cache: "no-cache" },
+ maxEntrySize: 1024 * 1024
})
})
@@ -17,7 +18,8 @@ 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
+ maxAge: 60 * 60 * 24 * 7,
+ maxEntrySize: 1024 * 1024
})
})
@@ -27,7 +29,8 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24 * 3,
- networkTimeout: 2
+ networkTimeout: 2,
+ maxEntrySize: 1024 * 1024
})
})
@@ -36,7 +39,9 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "storage",
maxAge: 60 * 60 * 24 * 7,
- networkTimeout: 2
+ networkTimeout: 2,
+ maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
+ maxEntries: 500
})
})
@@ -45,7 +50,8 @@ TurboOffline.addRule({
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
- networkTimeout: 3
+ networkTimeout: 3,
+ maxEntrySize: 1024 * 1024
})
})
From 089fcf94f8969b432ba113b18a924cd85836ab05 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 6 Feb 2026 12:37:39 +0100
Subject: [PATCH 11/33] Preload resources after service worker installation
When the service worker is registered for the first time, resources loaded
before it becomes active won't go through the service worker. These resources
may be served from the browser's HTTP cache on subsequent requests, bypassing
the service worker cache entirely.
The new `preload` option accepts a regex pattern. On first visit (when no
controller exists), it waits for the service worker to take control, then
sends a message with URLs from `performance.getEntriesByType("resource")`
that match the pattern. The service worker fetches and caches these resources.
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/javascript/initializers/offline.js | 7 ++++---
3 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 9dd682dce..758936188 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
+ revision: ca62e8bdad88d01914d512ad24424e0670f239c9
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 8f5c9dd95..a3552dacd 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 0becdbbcdbf607eab6632463bc63f94d64249b7b
+ revision: ca62e8bdad88d01914d512ad24424e0670f239c9
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js
index c5eff824e..446a9671e 100644
--- a/app/javascript/initializers/offline.js
+++ b/app/javascript/initializers/offline.js
@@ -1,6 +1,7 @@
import { Turbo } from "@hotwired/turbo-rails"
-Turbo.offline.start("/service-worker.js", {
- scope: "/",
- native: true
+Turbo.offline.start("/service-worker.js", {
+ scope: "/",
+ native: true,
+ preload: /\/assets\//
})
From 1c2de6ca79bdd5617e5668c6cb37bf525f3c7085 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 11:31:36 +0100
Subject: [PATCH 12/33] Clear offline cache on sign-in as well as sign-out
Rename logout controller to clear-offline-cache and attach it to the
magic link verification form so the service worker cache is cleared
when a different user signs in.
Co-Authored-By: Claude Opus 4.6
---
.../{logout_controller.js => clear_offline_cache_controller.js} | 0
app/views/my/menus/_settings.html.erb | 2 +-
app/views/sessions/magic_links/show.html.erb | 2 +-
app/views/users/show.html.erb | 2 +-
4 files changed, 3 insertions(+), 3 deletions(-)
rename app/javascript/controllers/{logout_controller.js => clear_offline_cache_controller.js} (100%)
diff --git a/app/javascript/controllers/logout_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js
similarity index 100%
rename from app/javascript/controllers/logout_controller.js
rename to app/javascript/controllers/clear_offline_cache_controller.js
diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb
index 6b711544b..bf34b991d 100644
--- a/app/views/my/menus/_settings.html.erb
+++ b/app/views/my/menus/_settings.html.erb
@@ -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", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } 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 %>
Sign out
<% end %>
<% end %>
diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb
index aba8411f5..968624a10 100644
--- a/app/views/sessions/magic_links/show.html.erb
+++ b/app/views/sessions/magic_links/show.html.erb
@@ -8,7 +8,7 @@
- <%= 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],
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index a285a4f5a..eb3e40c64 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -41,7 +41,7 @@
<% if Current.user == @user %>
- <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "logout", action: "submit->logout#clearCache" } } 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 %>
Sign out of Fizzy on this device
<% end %>
<% end %>
From de6d70cc2efa1aa3f972d2bb1427a1731c9c2710 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 11:45:06 +0100
Subject: [PATCH 13/33] Bump turbo-rails for Turbo.offline.clearCache()
Simplify the clear-offline-cache controller to use the new
Turbo.offline.clearCache() API instead of messaging the service
worker directly.
Co-Authored-By: Claude Opus 4.6
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
.../controllers/clear_offline_cache_controller.js | 6 +++---
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 758936188..8a49b38a5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: ca62e8bdad88d01914d512ad24424e0670f239c9
+ revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index a3552dacd..751dc55fb 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: ca62e8bdad88d01914d512ad24424e0670f239c9
+ revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/javascript/controllers/clear_offline_cache_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js
index bc0511a7f..7caa2e89b 100644
--- a/app/javascript/controllers/clear_offline_cache_controller.js
+++ b/app/javascript/controllers/clear_offline_cache_controller.js
@@ -1,8 +1,8 @@
import { Controller } from "@hotwired/stimulus"
+import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
- async clearCache() {
- const registration = await navigator.serviceWorker?.ready
- registration?.active?.postMessage({ action: "clearCache" })
+ clearCache() {
+ Turbo.offline.clearCache()
}
}
From 0482670fec187f3361531a6b8a4672430b65345f Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 12:07:38 +0100
Subject: [PATCH 14/33] Exclude service worker and edit/pin/watch/new pages
from main cache
We don't need the service worker to cache itself, or pages that won't
work offline anyway.
---
app/javascript/controllers/magic_link_controller.js | 2 +-
app/views/pwa/service_worker.js.erb | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js
index 3e2e4618c..8217af5aa 100644
--- a/app/javascript/controllers/magic_link_controller.js
+++ b/app/javascript/controllers/magic_link_controller.js
@@ -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
}
}
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 84f274673..095595f4e 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -5,6 +5,7 @@ importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
// 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,
@@ -47,6 +48,7 @@ TurboOffline.addRule({
// Everything else
TurboOffline.addRule({
+ except: /\/(service-worker\.js|edit|pin|watch|new)$/,
handler: TurboOffline.handlers.networkFirst({
cacheName: "main",
maxAge: 60 * 60 * 24,
From 99382db7203f1598c27d71b612a75230cc8b83b6 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 12:43:18 +0100
Subject: [PATCH 15/33] Only start offline mode when signed in
Gate Turbo.offline.start() behind Current.user so the service worker
is only registered for authenticated users. This avoids errors on
unauthenticated pages where /service-worker.js requires auth.
Co-Authored-By: Claude Opus 4.6
---
app/javascript/initializers/offline.js | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js
index 446a9671e..877ffa0ef 100644
--- a/app/javascript/initializers/offline.js
+++ b/app/javascript/initializers/offline.js
@@ -1,7 +1,9 @@
import { Turbo } from "@hotwired/turbo-rails"
-Turbo.offline.start("/service-worker.js", {
- scope: "/",
- native: true,
- preload: /\/assets\//
-})
+if (Current.user) {
+ Turbo.offline.start("/service-worker.js", {
+ scope: "/",
+ native: true,
+ preload: /\/assets\//
+ })
+}
From 67d629f72219b72936127bd12ae7721c4e7a33bb Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Fri, 20 Feb 2026 20:48:01 +0100
Subject: [PATCH 16/33] Use CORS fetch mode for Active Storage to enable
`maxEntrySize` checks
Otherwise, for resources like images loaded via tags, the browser
sets `mode: "no-cors"` (as these aren't CORS requests), so the service
worker gets an opaque response even though the server sends CORS
headers.
We could upgrade all no-cors requests to cors mode, sending a `mode:
"cors"` request to a server that doesn't send CORS headers will fail
entirely: the `fetch` call throws a `TypeError` (network error), and the
browser blocks the response. So the resource wouldn't load at all, not
even as opaque. We wouldn't be able to cache it at all.
By opting in via `fetchOptions`, we can do it only for rules where we
know the server will send CORS headers.
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/views/pwa/service_worker.js.erb | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 8a49b38a5..e811a1c2f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
+ revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 751dc55fb..af3c102c1 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 4104a353cfe728232dc0eed6d54c77466a9849f2
+ revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 095595f4e..5ab493e22 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -42,7 +42,8 @@ TurboOffline.addRule({
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
- maxEntries: 500
+ maxEntries: 500,
+ fetchOptions: { mode: "cors", credentials: "omit" }
})
})
From 1855490f805477db7bd0eebdca3fc52da1a8f0c7 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Sat, 21 Feb 2026 09:20:47 +0100
Subject: [PATCH 17/33] Revert CORS fetch mode for Active Storage
CORS mode doesn't work with redirect-based Active Storage URLs
when the storage bucket uses wildcard Access-Control-Allow-Origin.
Relying on maxEntries alone until specific origin CORS is available.
Co-Authored-By: Claude Opus 4.6
---
Gemfile.lock | 2 +-
Gemfile.saas.lock | 2 +-
app/views/pwa/service_worker.js.erb | 3 +--
3 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index e811a1c2f..8db6817dc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
+ revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index af3c102c1..37ff08294 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/hotwired/turbo-rails.git
- revision: 43bc205155b0eb57e5e016ed0e87a12c29ba2ab2
+ revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080
branch: offline-cache
specs:
turbo-rails (2.0.23)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 5ab493e22..095595f4e 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -42,8 +42,7 @@ TurboOffline.addRule({
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
- maxEntries: 500,
- fetchOptions: { mode: "cors", credentials: "omit" }
+ maxEntries: 500
})
})
From 49a86c7cbf7905d7cb67e1e69e2922040658a279 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 3 Mar 2026 15:59:25 +0000
Subject: [PATCH 18/33] Enable CORS fetch mode for storage requests
The load balancer now returns a specific `Access-Control-Allow-Origin`
instead of a wildcard, so credentials can be included by default.
This enables `maxEntrySize` enforcement for storage.
Co-Authored-By: Claude Opus 4.6
---
app/views/pwa/service_worker.js.erb | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb
index 095595f4e..41fac1446 100644
--- a/app/views/pwa/service_worker.js.erb
+++ b/app/views/pwa/service_worker.js.erb
@@ -42,7 +42,8 @@ TurboOffline.addRule({
maxAge: 60 * 60 * 24 * 7,
networkTimeout: 2,
maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
- maxEntries: 500
+ maxEntries: 500,
+ fetchOptions: { mode: "cors" }
})
})
From 71b99c1e18bf49f19839e088b8abad417a119fbb Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Tue, 10 Mar 2026 11:03:40 -0400
Subject: [PATCH 19/33] Fix path traversal vulnerability in ActiveStorage blob
key import
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BlobRecordSet#import_batch preserved blob keys from exported ZIP files
verbatim. A crafted ZIP with a traversal key like "../../config/deploy.yml"
would create a blob whose key resolves to an arbitrary filesystem path
when served by ActiveStorage's DiskService, allowing an attacker to read
local files.
Fix: generate fresh blob keys on import, discarding whatever key was in
the ZIP. This is safe because nothing else in the import pipeline
references blobs by key — attachments use blob_id, ActionText uses GIDs
based on record IDs, and only FileRecordSet used the old key for file
data lookup.
Update FileRecordSet to build an old_key→blob_id mapping from the blob
JSON metadata in the ZIP, then look up blobs by ID instead of by key.
Both check_record and import_batch are now fail-closed: they raise
IntegrityError for unmapped storage files, missing blobs, and duplicate
keys in the export.
Backfill test coverage for BlobRecordSet and FileRecordSet import, and
add a round-trip test verifying blob data survives export/import with
regenerated keys.
---
.../active_storage/blob_record_set.rb | 1 +
.../active_storage/file_record_set.rb | 38 +++-
test/integration/blob_key_traversal_test.rb | 45 +++++
.../active_storage/blob_record_set_test.rb | 58 ++++++
.../active_storage/file_record_set_test.rb | 177 ++++++++++++++++++
test/models/account/import_test.rb | 46 +++++
6 files changed, 357 insertions(+), 8 deletions(-)
create mode 100644 test/integration/blob_key_traversal_test.rb
create mode 100644 test/models/account/data_transfer/active_storage/blob_record_set_test.rb
create mode 100644 test/models/account/data_transfer/active_storage/file_record_set_test.rb
diff --git a/app/models/account/data_transfer/active_storage/blob_record_set.rb b/app/models/account/data_transfer/active_storage/blob_record_set.rb
index 1637768f3..0c4a553c1 100644
--- a/app/models/account/data_transfer/active_storage/blob_record_set.rb
+++ b/app/models/account/data_transfer/active_storage/blob_record_set.rb
@@ -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
diff --git a/app/models/account/data_transfer/active_storage/file_record_set.rb b/app/models/account/data_transfer/active_storage/file_record_set.rb
index 15d359353..421a2e520 100644
--- a/app/models/account/data_transfer/active_storage/file_record_set.rb
+++ b/app/models/account/data_transfer/active_storage/file_record_set.rb
@@ -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
diff --git a/test/integration/blob_key_traversal_test.rb b/test/integration/blob_key_traversal_test.rb
new file mode 100644
index 000000000..31dc8e179
--- /dev/null
+++ b/test/integration/blob_key_traversal_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class Account::DataTransfer::ActiveStorage::BlobKeyTraversalTest < ActionDispatch::IntegrationTest
+ test "import with path traversal blob key does not leak local files" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ traversal_key = "../../config/deploy.yml"
+ zip = build_zip_with_blob(id: blob_id, key: traversal_key)
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+ blob = ActiveStorage::Blob.find(blob_id)
+
+ assert_not_equal traversal_key, blob.key
+
+ sign_in_as identities(:david)
+ get rails_blob_path(blob, disposition: "inline")
+
+ assert_response :redirect
+
+ follow_redirect!
+
+ assert_response :not_found
+ end
+
+ private
+ def build_zip_with_blob(id:, key:)
+ tempfile = Tempfile.new([ "malicious", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{id}.json", {
+ id: id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 32,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "traversal.txt",
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+end
diff --git a/test/models/account/data_transfer/active_storage/blob_record_set_test.rb b/test/models/account/data_transfer/active_storage/blob_record_set_test.rb
new file mode 100644
index 000000000..82b83f428
--- /dev/null
+++ b/test/models/account/data_transfer/active_storage/blob_record_set_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+
+class Account::DataTransfer::ActiveStorage::BlobRecordSetTest < ActiveSupport::TestCase
+ test "import generates fresh keys instead of using exported keys" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ exported_key = "original-exported-key-abc123"
+
+ zip = build_zip_with_blob(id: blob_id, key: exported_key)
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_not_equal exported_key, blob.key
+ assert_equal 28, blob.key.length
+ end
+
+ test "import preserves blob metadata" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+
+ zip = build_zip_with_blob(
+ id: blob_id,
+ key: "some-key",
+ filename: "report.pdf",
+ content_type: "application/pdf",
+ byte_size: 12345,
+ checksum: "abc123checksum"
+ )
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_equal "report.pdf", blob.filename.to_s
+ assert_equal "application/pdf", blob.content_type
+ assert_equal 12345, blob.byte_size
+ assert_equal "abc123checksum", blob.checksum
+ end
+
+ private
+ def build_zip_with_blob(id:, key:, filename: "test.txt", content_type: "text/plain", byte_size: 32, checksum: "")
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{id}.json", {
+ id: id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: byte_size,
+ checksum: checksum,
+ content_type: content_type,
+ created_at: Time.current.iso8601,
+ filename: filename,
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+end
diff --git a/test/models/account/data_transfer/active_storage/file_record_set_test.rb b/test/models/account/data_transfer/active_storage/file_record_set_test.rb
new file mode 100644
index 000000000..4784754ae
--- /dev/null
+++ b/test/models/account/data_transfer/active_storage/file_record_set_test.rb
@@ -0,0 +1,177 @@
+require "test_helper"
+
+class Account::DataTransfer::ActiveStorage::FileRecordSetTest < ActiveSupport::TestCase
+ test "import uploads file data to blobs with regenerated keys" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "original-key-for-file"
+ file_content = "hello world file content"
+
+ zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content)
+
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_not_equal old_key, blob.key
+ assert_equal file_content, blob.download
+ end
+
+ test "import handles keys containing path separators" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "folder/subfolder/file-key"
+ file_content = "nested key content"
+
+ zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content)
+
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+
+ blob = ActiveStorage::Blob.find(blob_id)
+ assert_not_equal old_key, blob.key
+ assert_equal file_content, blob.download
+ end
+
+ test "import raises IntegrityError for storage file without matching blob metadata" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "key-with-metadata"
+ orphan_key = "orphaned-storage-key"
+
+ zip = build_zip_with_orphaned_storage_file(
+ blob_id: blob_id,
+ old_key: old_key,
+ orphan_key: orphan_key
+ )
+
+ Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip)
+
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+ end
+ end
+
+ test "import raises IntegrityError when mapped blob is not found in database" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "key-for-missing-blob"
+
+ zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: "data")
+
+ # Import file data WITHOUT importing blob metadata first
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+ end
+ end
+
+ test "check raises IntegrityError for storage file without matching blob metadata" do
+ blob_id = ActiveRecord::Type::Uuid.generate
+ old_key = "key-with-metadata"
+ orphan_key = "orphaned-storage-key"
+
+ zip = build_zip_with_orphaned_storage_file(
+ blob_id: blob_id,
+ old_key: old_key,
+ orphan_key: orphan_key
+ )
+
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).check(from: zip)
+ end
+ end
+
+ test "import raises IntegrityError for duplicate blob keys in export" do
+ blob_id_1 = ActiveRecord::Type::Uuid.generate
+ blob_id_2 = ActiveRecord::Type::Uuid.generate
+ duplicate_key = "same-key-for-both"
+
+ zip = build_zip_with_duplicate_keys(
+ blob_id_1: blob_id_1,
+ blob_id_2: blob_id_2,
+ key: duplicate_key
+ )
+
+ assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip)
+ end
+ end
+
+ private
+ def build_zip_with_blob_and_file(blob_id:, old_key:, file_content:)
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{blob_id}.json", {
+ id: blob_id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: file_content.bytesize,
+ checksum: Digest::MD5.base64digest(file_content),
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "test.txt",
+ key: old_key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("storage/#{old_key}", file_content, compress: false)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+
+ def build_zip_with_orphaned_storage_file(blob_id:, old_key:, orphan_key:)
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{blob_id}.json", {
+ id: blob_id,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 10,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "test.txt",
+ key: old_key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("storage/#{old_key}", "file data", compress: false)
+ writer.add_file("storage/#{orphan_key}", "orphan data", compress: false)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+
+ def build_zip_with_duplicate_keys(blob_id_1:, blob_id_2:, key:)
+ tempfile = Tempfile.new([ "test_export", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/active_storage_blobs/#{blob_id_1}.json", {
+ id: blob_id_1,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 10,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "file1.txt",
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("data/active_storage_blobs/#{blob_id_2}.json", {
+ id: blob_id_2,
+ account_id: ActiveRecord::Type::Uuid.generate,
+ byte_size: 10,
+ checksum: "",
+ content_type: "text/plain",
+ created_at: Time.current.iso8601,
+ filename: "file2.txt",
+ key: key,
+ metadata: {}
+ }.to_json)
+ writer.add_file("storage/#{key}", "file data", compress: false)
+ writer.close
+
+ tempfile.rewind
+ ZipFile::Reader.new(tempfile)
+ end
+end
diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb
index 7bf407041..de384b385 100644
--- a/test/models/account/import_test.rb
+++ b/test/models/account/import_test.rb
@@ -174,6 +174,52 @@ class Account::ImportTest < ActiveSupport::TestCase
export_tempfile&.unlink
end
+ test "export and import round-trip preserves blobs and attachments" do
+ source_account = accounts("37s")
+ exporter = users(:david)
+ identity = exporter.identity
+
+ ActiveStorage::Blob.create_and_upload!(
+ io: StringIO.new("test image data"),
+ filename: "logo.png",
+ content_type: "image/png"
+ )
+
+ source_blob_count = ActiveStorage::Blob.where(account: source_account).count
+ source_blob_keys = ActiveStorage::Blob.where(account: source_account).pluck(:key)
+
+ assert_operator source_blob_count, :>, 0
+
+ export = Account::Export.create!(account: source_account, user: exporter)
+ export.build
+
+ export_tempfile = Tempfile.new([ "export", ".zip" ])
+ export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }
+
+ ActiveStorage::Blob.where(account: source_account).delete_all
+ source_account.destroy!
+
+ target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name })
+ import = Account::Import.create!(identity: identity, account: target_account)
+ Current.set(account: target_account) do
+ import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip")
+ end
+
+ import.check
+ assert_not import.failed?
+
+ import.process
+ assert import.completed?
+
+ imported_blob = ActiveStorage::Blob.find_by(account: target_account, filename: "logo.png")
+ assert_not_nil imported_blob
+ assert_not_includes source_blob_keys, imported_blob.key
+ assert_equal "test image data", imported_blob.download
+ ensure
+ export_tempfile&.close
+ export_tempfile&.unlink
+ end
+
private
def account_digest(account)
{
From 8c2318e2674cbafed12cf63e9a1faac5b02eecdb Mon Sep 17 00:00:00 2001
From: Rob Zolkos
Date: Tue, 10 Mar 2026 11:31:00 -0400
Subject: [PATCH 20/33] Add JSON response format to entropy endpoints (#2673)
* Add JSON response format to entropy endpoints
Add account settings JSON endpoint returning account info including auto_postpone_period. Add auto_postpone_period to board JSON responses. Add JSON update support to both account and board entropy controllers.
* Add auto_postpone_period to all board response examples in API docs
* Use auto_postpone_period_in_days in JSON responses and API docs
* Use valid period values in permission tests
---
.../account/entropies_controller.rb | 5 +-
.../account/settings_controller.rb | 5 +-
.../boards/entropies_controller.rb | 2 +-
app/views/account/settings/show.json.jbuilder | 4 +-
app/views/boards/_board.json.jbuilder | 1 +
docs/API.md | 78 ++++++++++++++++++-
.../accounts/entropies_controller_test.rb | 19 ++++-
.../accounts/settings_controller_test.rb | 16 ++--
test/controllers/api/flat_json_params_test.rb | 4 +-
.../boards/entropies_controller_test.rb | 29 ++++++-
test/controllers/boards_controller_test.rb | 1 +
11 files changed, 143 insertions(+), 21 deletions(-)
diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb
index 327098ee2..44c9debe5 100644
--- a/app/controllers/account/entropies_controller.rb
+++ b/app/controllers/account/entropies_controller.rb
@@ -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
diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb
index 47036d606..e4b949070 100644
--- a/app/controllers/account/settings_controller.rb
+++ b/app/controllers/account/settings_controller.rb
@@ -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
diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb
index 132733aac..89e262545 100644
--- a/app/controllers/boards/entropies_controller.rb
+++ b/app/controllers/boards/entropies_controller.rb
@@ -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
diff --git a/app/views/account/settings/show.json.jbuilder b/app/views/account/settings/show.json.jbuilder
index 9a62dabc6..55b623f9d 100644
--- a/app/views/account/settings/show.json.jbuilder
+++ b/app/views/account/settings/show.json.jbuilder
@@ -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
diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder
index 7c85a1dc5..0eeff1896 100644
--- a/app/views/boards/_board.json.jbuilder
+++ b/app/views/boards/_board.json.jbuilder
@@ -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
diff --git a/docs/API.md b/docs/API.md
index cec58f36e..e8b60697c 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -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",
diff --git a/test/controllers/accounts/entropies_controller_test.rb b/test/controllers/accounts/entropies_controller_test.rb
index d00c0d768..fa2819d48 100644
--- a/test/controllers/accounts/entropies_controller_test.rb
+++ b/test/controllers/accounts/entropies_controller_test.rb
@@ -16,8 +16,9 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
test "update as JSON" do
put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json
- assert_response :no_content
+ assert_response :success
assert_equal 7.days, entropies("37s_account").reload.auto_postpone_period
+ assert_equal 7, @response.parsed_body["auto_postpone_period_in_days"]
end
test "update requires admin" do
@@ -35,4 +36,20 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_equal original_period, entropies("37s_account").reload.auto_postpone_period
end
+
+ test "update as JSON rejects invalid auto_postpone_period" do
+ original_period = entropies("37s_account").auto_postpone_period
+
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_equal original_period, entropies("37s_account").reload.auto_postpone_period
+ end
+
+ test "update as JSON requires admin" do
+ logout_and_sign_in_as :david
+
+ put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json
+ assert_response :forbidden
+ end
end
diff --git a/test/controllers/accounts/settings_controller_test.rb b/test/controllers/accounts/settings_controller_test.rb
index 6f3fba5b0..88a6ef137 100644
--- a/test/controllers/accounts/settings_controller_test.rb
+++ b/test/controllers/accounts/settings_controller_test.rb
@@ -16,13 +16,6 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to account_settings_path
end
- test "show as JSON" do
- get account_settings_path, as: :json
- assert_response :success
-
- assert_equal Current.account.name, @response.parsed_body["name"]
- end
-
test "update as JSON" do
put account_settings_path, params: { account: { name: "New Account Name" } }, as: :json
@@ -36,4 +29,13 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest
put account_settings_path, params: { account: { name: "New Account Name" } }
assert_response :forbidden
end
+
+ test "show as JSON" do
+ get account_settings_path, as: :json
+
+ assert_response :success
+ assert_equal Current.account.name, @response.parsed_body["name"]
+ assert_equal Current.account.cards_count, @response.parsed_body["cards_count"]
+ assert_equal Current.account.entropy.auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"]
+ end
end
diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb
index 90f55f8fd..7a9a5ea5d 100644
--- a/test/controllers/api/flat_json_params_test.rb
+++ b/test/controllers/api/flat_json_params_test.rb
@@ -41,14 +41,14 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
put board_entropy_path(board), params: { auto_postpone_period_in_days: 90 }, as: :json
- assert_response :no_content
+ assert_response :success
assert_equal 90.days, board.entropy.reload.auto_postpone_period
end
test "update account entropy with flat JSON" do
put account_entropy_path, params: { auto_postpone_period_in_days: 7 }, as: :json
- assert_response :no_content
+ assert_response :success
assert_equal 7.days, Current.account.entropy.reload.auto_postpone_period
end
diff --git a/test/controllers/boards/entropies_controller_test.rb b/test/controllers/boards/entropies_controller_test.rb
index e0fd4fa52..4173694ca 100644
--- a/test/controllers/boards/entropies_controller_test.rb
+++ b/test/controllers/boards/entropies_controller_test.rb
@@ -17,10 +17,13 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
end
test "update as JSON" do
- put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json
+ assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json
- assert_response :no_content
- assert_equal 90.days, @board.entropy.reload.auto_postpone_period
+ assert_response :success
+ assert_equal 90.days, @board.entropy.reload.auto_postpone_period
+ assert_equal 90, @response.parsed_body["auto_postpone_period_in_days"]
+ end
end
test "update requires board admin permission" do
@@ -42,4 +45,24 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_equal original_period, @board.entropy.reload.auto_postpone_period
end
+
+ test "update as JSON rejects invalid auto_postpone_period" do
+ original_period = @board.entropy.auto_postpone_period
+
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 1 } }, as: :json
+
+ assert_response :unprocessable_entity
+ assert_equal original_period, @board.entropy.reload.auto_postpone_period
+ end
+
+ test "update as JSON requires board admin permission" do
+ logout_and_sign_in_as :jz
+
+ original_period = @board.entropy.auto_postpone_period
+
+ put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 7 } }, as: :json
+
+ assert_response :forbidden
+ assert_equal original_period, @board.entropy.reload.auto_postpone_period
+ end
end
diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb
index 915310108..059c38026 100644
--- a/test/controllers/boards_controller_test.rb
+++ b/test/controllers/boards_controller_test.rb
@@ -258,6 +258,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
get board_path(boards(:writebook)), as: :json
assert_response :success
assert_equal boards(:writebook).name, @response.parsed_body["name"]
+ assert_equal boards(:writebook).auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"]
end
test "show as JSON includes public_url when published" do
From 66039c92f3c30487551202b79efe035395c8d7d8 Mon Sep 17 00:00:00 2001
From: Rosa Gutierrez
Date: Tue, 10 Mar 2026 15:27:42 +0100
Subject: [PATCH 21/33] Use x_user_agent cookie for platform detection in
Hotwire Native
Turbo's offline/service worker sets an x_user_agent cookie with the
original browser user agent. This ensures platform detection works
correctly for Hotwire Native apps where service worker requests may
not carry the overridden user agent header.
---
app/controllers/concerns/set_platform.rb | 2 +-
.../controllers/concerns/set_platform_test.rb | 21 +++++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 test/controllers/concerns/set_platform_test.rb
diff --git a/app/controllers/concerns/set_platform.rb b/app/controllers/concerns/set_platform.rb
index 54e77d517..66de85f12 100644
--- a/app/controllers/concerns/set_platform.rb
+++ b/app/controllers/concerns/set_platform.rb
@@ -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
diff --git a/test/controllers/concerns/set_platform_test.rb b/test/controllers/concerns/set_platform_test.rb
new file mode 100644
index 000000000..26ce9d66a
--- /dev/null
+++ b/test/controllers/concerns/set_platform_test.rb
@@ -0,0 +1,21 @@
+require "test_helper"
+
+class SetPlatformTest < ActionDispatch::IntegrationTest
+ DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ NATIVE_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0"
+
+ test "uses the request user agent by default" do
+ sign_in_as :david
+
+ get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA }
+ assert_match 'data-platform="desktop web"', response.body
+ end
+
+ test "prefers x_user_agent cookie over request user agent" do
+ sign_in_as :david
+
+ cookies[:x_user_agent] = NATIVE_IOS_UA
+ get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA }
+ assert_match 'data-platform="native ios"', response.body
+ end
+end
From 0f5216f4332da48b579b429a5bdd795f0c4303a5 Mon Sep 17 00:00:00 2001
From: Jeffrey Hardy
Date: Tue, 10 Mar 2026 14:44:50 -0400
Subject: [PATCH 22/33] Fix that the "maybe" stream wouldn't be replaced after
triaging a card
The element was originally named "the-stream" and later renamed to "maybe",
but the original references weren't updated.
Refs:
- https://github.com/basecamp/fizzy/pull/2211
- https://github.com/basecamp/fizzy/commit/b3cfae35
---
app/views/boards/show/_stream.html.erb | 2 +-
app/views/cards/closures/create.turbo_stream.erb | 2 +-
app/views/cards/closures/destroy.turbo_stream.erb | 2 +-
app/views/cards/not_nows/create.turbo_stream.erb | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb
index b43309100..f66fb8fa6 100644
--- a/app/views/boards/show/_stream.html.erb
+++ b/app/views/boards/show/_stream.html.erb
@@ -12,7 +12,7 @@
<% 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 %>
diff --git a/app/views/cards/closures/create.turbo_stream.erb b/app/views/cards/closures/create.turbo_stream.erb
index c4898e471..5b76efe7b 100644
--- a/app/views/cards/closures/create.turbo_stream.erb
+++ b/app/views/cards/closures/create.turbo_stream.erb
@@ -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 }) %>
diff --git a/app/views/cards/closures/destroy.turbo_stream.erb b/app/views/cards/closures/destroy.turbo_stream.erb
index 8df408979..31a9e41c3 100644
--- a/app/views/cards/closures/destroy.turbo_stream.erb
+++ b/app/views/cards/closures/destroy.turbo_stream.erb
@@ -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 }) %>
diff --git a/app/views/cards/not_nows/create.turbo_stream.erb b/app/views/cards/not_nows/create.turbo_stream.erb
index 9128580e2..945d86ab2 100644
--- a/app/views/cards/not_nows/create.turbo_stream.erb
+++ b/app/views/cards/not_nows/create.turbo_stream.erb
@@ -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 }) %>
From 8fb57ace010e462811a41ad1266bdbf4b699c29b Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Mon, 9 Mar 2026 16:13:18 -0400
Subject: [PATCH 23/33] Validate polymorphic types against importable models
allowlist
Add IMPORTABLE_MODEL_NAMES to RecordSet and verify polymorphic type
columns during import check against this allowlist before calling
constantize. This prevents arbitrary class instantiation from
untrusted import ZIP data.
---
.../account/data_transfer/record_set.rb | 45 +++++++++-
.../account/data_transfer/record_set_test.rb | 89 +++++++++++++++++++
2 files changed, 133 insertions(+), 1 deletion(-)
create mode 100644 test/models/account/data_transfer/record_set_test.rb
diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb
index 8eeafbe10..2d64d2e1c 100644
--- a/app/models/account/data_transfer/record_set.rb
+++ b/app/models/account/data_transfer/record_set.rb
@@ -4,6 +4,41 @@ class Account::DataTransfer::RecordSet
IMPORT_BATCH_SIZE = 100
+ IMPORTABLE_MODEL_NAMES = %w[
+ Access
+ Account
+ ActionText::RichText
+ ActiveStorage::Attachment
+ ActiveStorage::Blob
+ Assignment
+ Board
+ Board::Publication
+ Card
+ Card::ActivitySpike
+ Card::Goldness
+ Card::NotNow
+ Closure
+ Column
+ Comment
+ Entropy
+ Event
+ Filter
+ Mention
+ Notification
+ Notification::Bundle
+ Pin
+ Reaction
+ Step
+ Tag
+ Tagging
+ User
+ User::Settings
+ Watch
+ Webhook
+ Webhook::DelinquencyTracker
+ Webhook::Delivery
+ ].freeze
+
attr_reader :account, :model, :attributes
def initialize(account:, model:, attributes: nil)
@@ -113,7 +148,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 +158,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)
diff --git a/test/models/account/data_transfer/record_set_test.rb b/test/models/account/data_transfer/record_set_test.rb
new file mode 100644
index 000000000..74b457edf
--- /dev/null
+++ b/test/models/account/data_transfer/record_set_test.rb
@@ -0,0 +1,89 @@
+require "test_helper"
+
+class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
+ test "check rejects polymorphic type not in the importable models allowlist" do
+ event_data = build_event_data(eventable_type: "Identity")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+
+ assert_match(/unrecognized.*type/i, error.message)
+ end
+
+ test "check rejects nonexistent polymorphic type" do
+ event_data = build_event_data(eventable_type: "Nonexistent::ClassName")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+
+ assert_match(/unrecognized.*type/i, error.message)
+ end
+
+ test "check rejects non-ActiveRecord class used as polymorphic type" do
+ event_data = build_event_data(eventable_type: "ActiveSupport::BroadcastLogger")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+
+ assert_match(/unrecognized.*type/i, error.message)
+ end
+
+ test "check accepts polymorphic type in the importable models allowlist" do
+ event_data = build_event_data(eventable_type: "Card")
+
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+
+ assert_nothing_raised do
+ record_set.check(from: build_reader(dir: "events", data: event_data))
+ end
+ end
+
+ private
+ def importing_account
+ @importing_account ||= Account.create!(name: "Importing Account", external_account_id: 99999999)
+ end
+
+ def build_event_data(eventable_type:)
+ {
+ "id" => "test_event_id_12345678901234",
+ "account_id" => "nonexistent_account_id_1234567",
+ "board_id" => "nonexistent_board_id_12345678",
+ "creator_id" => "nonexistent_user_id_123456789",
+ "eventable_type" => eventable_type,
+ "eventable_id" => "nonexistent_id_1234567890123",
+ "action" => "created",
+ "particulars" => "{}",
+ "created_at" => Time.current.iso8601,
+ "updated_at" => Time.current.iso8601
+ }
+ end
+
+ def build_reader(dir:, data:)
+ tempfile = Tempfile.new([ "import_test", ".zip" ])
+ tempfile.binmode
+
+ writer = ZipFile::Writer.new(tempfile)
+ writer.add_file("data/#{dir}/#{data['id']}.json", data.to_json)
+ writer.close
+ tempfile.rewind
+
+ @tempfiles ||= []
+ @tempfiles << tempfile
+
+ ZipFile::Reader.new(tempfile)
+ end
+
+ def teardown
+ @tempfiles&.each { |f| f.close; f.unlink }
+ @importing_account&.destroy
+ end
+end
From b4fd22644c7e1d2311431254a26d36d141e711b3 Mon Sep 17 00:00:00 2001
From: "Stanko K.R."
Date: Tue, 10 Mar 2026 09:48:23 +0100
Subject: [PATCH 24/33] Set the list of importable models based on the record
sets in the manifest
---
app/models/account/data_transfer/manifest.rb | 42 +++++++++++++++----
.../account/data_transfer/record_set.rb | 41 ++----------------
.../action_text/rich_text_record_set_test.rb | 1 +
.../account/data_transfer/record_set_test.rb | 12 ++++--
4 files changed, 48 insertions(+), 48 deletions(-)
diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb
index 2159926d5..9de76f9bf 100644
--- a/app/models/account/data_transfer/manifest.rb
+++ b/app/models/account/data_transfer/manifest.rb
@@ -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
diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb
index 2d64d2e1c..2fba9402d 100644
--- a/app/models/account/data_transfer/record_set.rb
+++ b/app/models/account/data_transfer/record_set.rb
@@ -4,47 +4,14 @@ class Account::DataTransfer::RecordSet
IMPORT_BATCH_SIZE = 100
- IMPORTABLE_MODEL_NAMES = %w[
- Access
- Account
- ActionText::RichText
- ActiveStorage::Attachment
- ActiveStorage::Blob
- Assignment
- Board
- Board::Publication
- Card
- Card::ActivitySpike
- Card::Goldness
- Card::NotNow
- Closure
- Column
- Comment
- Entropy
- Event
- Filter
- Mention
- Notification
- Notification::Bundle
- Pin
- Reaction
- Step
- Tag
- Tagging
- User
- User::Settings
- Watch
- Webhook
- Webhook::DelinquencyTracker
- Webhook::Delivery
- ].freeze
-
+ 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)
@@ -159,7 +126,7 @@ class Account::DataTransfer::RecordSet
end
def verify_model_type(type_name)
- if IMPORTABLE_MODEL_NAMES.include?(type_name)
+ if importable_model_names.include?(type_name)
type_name.constantize
else
raise IntegrityError, "Unrecognized model type: #{type_name}"
diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb
index a28935a3e..2fe2c4b93 100644
--- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb
+++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb
@@ -30,6 +30,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport::
reader = ZipFile::Reader.new(tempfile)
record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(importing_account)
+ record_set.importable_model_names = %w[ ActionText::RichText Card ]
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: reader)
diff --git a/test/models/account/data_transfer/record_set_test.rb b/test/models/account/data_transfer/record_set_test.rb
index 74b457edf..8c8d75e5e 100644
--- a/test/models/account/data_transfer/record_set_test.rb
+++ b/test/models/account/data_transfer/record_set_test.rb
@@ -1,10 +1,14 @@
require "test_helper"
class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
+ setup do
+ @importable_model_names = %w[ Card Board Event ]
+ end
+
test "check rejects polymorphic type not in the importable models allowlist" do
event_data = build_event_data(eventable_type: "Identity")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: build_reader(dir: "events", data: event_data))
@@ -16,7 +20,7 @@ class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
test "check rejects nonexistent polymorphic type" do
event_data = build_event_data(eventable_type: "Nonexistent::ClassName")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: build_reader(dir: "events", data: event_data))
@@ -28,7 +32,7 @@ class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
test "check rejects non-ActiveRecord class used as polymorphic type" do
event_data = build_event_data(eventable_type: "ActiveSupport::BroadcastLogger")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
record_set.check(from: build_reader(dir: "events", data: event_data))
@@ -40,7 +44,7 @@ class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase
test "check accepts polymorphic type in the importable models allowlist" do
event_data = build_event_data(eventable_type: "Card")
- record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event)
+ record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names)
assert_nothing_raised do
record_set.check(from: build_reader(dir: "events", data: event_data))
From 99e5af6f9525172f7c22bbfbc5a1998c062825df Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Wed, 11 Mar 2026 13:19:48 +0100
Subject: [PATCH 25/33] Tweak the layout
... left aligning
---
app/assets/stylesheets/card-perma.css | 77 +++++++++++++++++++++------
1 file changed, 60 insertions(+), 17 deletions(-)
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index 3dc16d5a2..5db9e7df7 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -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;
+ }
}
}
From 7a6c905a323b91f6a6f3678692a26e480f089e3c Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Wed, 11 Mar 2026 13:20:40 +0100
Subject: [PATCH 26/33] Prevent line breaks in the middle of familiar_name
---
app/models/user/named.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/models/user/named.rb b/app/models/user/named.rb
index d052a79ad..73cc763de 100644
--- a/app/models/user/named.rb
+++ b/app/models/user/named.rb
@@ -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} #{names[1..].map { |n| "#{n[0]}." }.join}".html_safe
end
end
From 31975be1fa3c737718a90d153f21617fd59d0eb5 Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Wed, 11 Mar 2026 13:36:09 +0100
Subject: [PATCH 27/33] Update app/models/user/named.rb
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
app/models/user/named.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/models/user/named.rb b/app/models/user/named.rb
index 73cc763de..804e516ee 100644
--- a/app/models/user/named.rb
+++ b/app/models/user/named.rb
@@ -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}".html_safe
+ "#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
From ab5283441d2309426f91c41a5efc6e7b88c3c3fc Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Wed, 11 Mar 2026 10:11:13 -0400
Subject: [PATCH 28/33] SaaS usage reporting (#2690)
* Add saas:usage_report rake task and extract Subscription.paid scope
Add a rake task to generate a CSV usage report with per-account data:
Queenbee ID, sign up date, paid date, card count, storage used, and
last active date.
Extract the paid subscriptions query from Admin::StatsController into
an Account::Subscription.paid scope so both the controller and the
new rake task can share it.
* Add comped and account name columns to usage report
* Update saas/lib/tasks/fizzy/usage_report.rake
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Preload storage_total to avoid N+1 in usage report
* Batch last_active_at queries to avoid per-account aggregates
* Add tests for Account::Subscription.paid scope
* Update saas/app/models/account/subscription.rb
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Aggregate paid dates in SQL instead of loading all subscriptions
* Fix paid scope to derive plan keys from Plan.all instead of PLANS hash
* Move paid dates and comped lookups into per-batch queries
* Materialize batch IDs to avoid cross-database subquery
SaasRecord models live on a separate database (fizzy_saas) that doesn't
have the accounts table. Using batch.select(:id) generated a subquery
that ran on the saas database, causing a table-not-found error. Using
pluck(:id) materializes the IDs into an array instead.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
Gemfile.saas | 1 +
Gemfile.saas.lock | 2 +
.../app/controllers/admin/stats_controller.rb | 14 ++---
saas/app/models/account/subscription.rb | 6 +++
saas/lib/tasks/fizzy/usage_report.rake | 37 ++++++++++++++
saas/test/models/account/subscription_test.rb | 51 +++++++++++++++++++
6 files changed, 100 insertions(+), 11 deletions(-)
create mode 100644 saas/lib/tasks/fizzy/usage_report.rake
diff --git a/Gemfile.saas b/Gemfile.saas
index 39aa49a71..e57fcf206 100644
--- a/Gemfile.saas
+++ b/Gemfile.saas
@@ -10,6 +10,7 @@ gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
+gem "csv"
# Native push notifications (iOS/Android)
gem "action_push_native"
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 37ff08294..e4ed7406e 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -281,6 +281,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
+ csv (3.3.5)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
@@ -720,6 +721,7 @@ DEPENDENCIES
bundler-audit
capybara
console1984!
+ csv
debug
faker
fizzy-saas!
diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb
index 6b9592fca..287b49024 100644
--- a/saas/app/controllers/admin/stats_controller.rb
+++ b/saas/app/controllers/admin/stats_controller.rb
@@ -6,9 +6,9 @@ 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)
+ @paid_accounts_total = Account::Subscription.paid.distinct.count(:account_id)
+ @paid_accounts_last_7_days = Account::Subscription.paid.where(created_at: 7.days.ago..).distinct.count(:account_id)
+ @paid_accounts_last_24_hours = Account::Subscription.paid.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
@@ -21,12 +21,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
diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb
index 2ae7e3171..c1ec9e47c 100644
--- a/saas/app/models/account/subscription.rb
+++ b/saas/app/models/account/subscription.rb
@@ -3,6 +3,12 @@ class Account::Subscription < SaasRecord
enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
+ scope :paid, -> {
+ where(status: %w[active trialing past_due])
+ .where(plan_key: Plan.all.select(&:paid?).map(&:key))
+ .where.not(account_id: Account::BillingWaiver.select(:account_id))
+ }
+
validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
delegate :paid?, to: :plan
diff --git a/saas/lib/tasks/fizzy/usage_report.rake b/saas/lib/tasks/fizzy/usage_report.rake
new file mode 100644
index 000000000..d8b3ba108
--- /dev/null
+++ b/saas/lib/tasks/fizzy/usage_report.rake
@@ -0,0 +1,37 @@
+require "csv"
+
+namespace :saas do
+ desc "Generate a CSV usage report for all active accounts"
+ task usage_report: :environment do
+ output_path = Rails.root.join("tmp/usage_report.csv")
+
+ CSV.open(output_path, "w") do |csv|
+ csv << [ "Queenbee ID", "Account Name", "Sign Up Date", "Paid Date", "Comped", "Card Count", "Storage Used (Bytes)", "Last Active" ]
+
+ Account.active.includes(:storage_total).in_batches do |batch|
+ batch_ids = batch.pluck(:id)
+ paid_dates = Account::Subscription.paid.where(account_id: batch_ids)
+ .group(:account_id).minimum(:created_at)
+ comped_account_ids = Account::BillingWaiver.where(account_id: batch_ids)
+ .pluck(:account_id).to_set
+ last_active_dates = Card.where(account_id: batch_ids)
+ .group(:account_id).maximum(:last_active_at)
+
+ batch.each do |account|
+ csv << [
+ account.external_account_id,
+ account.name,
+ account.created_at.to_date,
+ paid_dates[account.id]&.to_date,
+ comped_account_ids.include?(account.id),
+ account.cards_count,
+ account.bytes_used,
+ last_active_dates[account.id]&.to_date
+ ]
+ end
+ end
+ end
+
+ puts "Report written to #{output_path}"
+ end
+end
diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb
index eaf7e103d..f9f16f2bb 100644
--- a/saas/test/models/account/subscription_test.rb
+++ b/saas/test/models/account/subscription_test.rb
@@ -16,6 +16,57 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
end
+ test "paid scope includes active paid subscriptions" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "active",
+ stripe_customer_id: "cus_paid"
+ )
+
+ assert_includes Account::Subscription.paid, subscription
+ end
+
+ test "paid scope includes trialing and past_due subscriptions" do
+ trialing = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "trialing",
+ stripe_customer_id: "cus_trialing"
+ )
+ past_due = Account::Subscription.create!(
+ account: accounts(:acme), plan_key: "monthly_extra_storage_v1", status: "past_due",
+ stripe_customer_id: "cus_past_due"
+ )
+
+ assert_includes Account::Subscription.paid, trialing
+ assert_includes Account::Subscription.paid, past_due
+ end
+
+ test "paid scope excludes free plan subscriptions" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "free_v1", status: "active",
+ stripe_customer_id: "cus_free"
+ )
+
+ assert_not_includes Account::Subscription.paid, subscription
+ end
+
+ test "paid scope excludes canceled subscriptions" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "canceled",
+ stripe_customer_id: "cus_canceled"
+ )
+
+ assert_not_includes Account::Subscription.paid, subscription
+ end
+
+ test "paid scope excludes comped accounts" do
+ subscription = Account::Subscription.create!(
+ account: accounts(:initech), plan_key: "monthly_v1", status: "active",
+ stripe_customer_id: "cus_comped"
+ )
+ Account::BillingWaiver.create!(account: accounts(:initech))
+
+ assert_not_includes Account::Subscription.paid, subscription
+ end
+
test "pause pauses Stripe subscription with void behavior" do
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
From d6bf103efd5d284518c656e11e28acfa5dab634d Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Wed, 11 Mar 2026 17:49:04 -0400
Subject: [PATCH 29/33] Configure Lexxy to add extra spacing between block
elements
Updating Lexxy to v0.8.0 for the insert-markdown event.
---
Gemfile | 2 +-
Gemfile.lock | 11 +++--------
Gemfile.saas.lock | 11 +++--------
app/javascript/initializers/index.js | 1 +
app/javascript/initializers/lexxy_markdown_paste.js | 3 +++
5 files changed, 11 insertions(+), 17 deletions(-)
create mode 100644 app/javascript/initializers/lexxy_markdown_paste.js
diff --git a/Gemfile b/Gemfile
index da66e1473..f5e26850a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -27,7 +27,7 @@ gem "geared_pagination", "~> 1.2"
gem "rqrcode"
gem "rouge"
gem "jbuilder"
-gem "lexxy", bc: "lexxy"
+gem "lexxy", "0.8.0.beta"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 8db6817dc..37579ff78 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,10 +1,3 @@
-GIT
- remote: https://github.com/basecamp/lexxy
- revision: b7f0f1429df5fa425eac043191ae2b65edee7488
- specs:
- lexxy (0.7.6.beta)
- rails (>= 8.0.2)
-
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -261,6 +254,8 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ lexxy (0.8.0.beta)
+ rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -523,7 +518,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy!
+ lexxy (= 0.8.0.beta)
mission_control-jobs
mittens
mocha
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index e4ed7406e..dbaaa9df4 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,13 +21,6 @@ GIT
rails (>= 7.0)
rainbow
-GIT
- remote: https://github.com/basecamp/lexxy
- revision: b7f0f1429df5fa425eac043191ae2b65edee7488
- specs:
- lexxy (0.7.6.beta)
- rails (>= 8.0.2)
-
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 14312a940471e20617b38cdec7c092a01567d18b
@@ -375,6 +368,8 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ lexxy (0.8.0.beta)
+ rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -732,7 +727,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy!
+ lexxy (= 0.8.0.beta)
mission_control-jobs
mittens
mocha
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 1d16ef205..d2c20451e 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,3 +1,4 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
+import "initializers/lexxy_markdown_paste"
diff --git a/app/javascript/initializers/lexxy_markdown_paste.js b/app/javascript/initializers/lexxy_markdown_paste.js
new file mode 100644
index 000000000..cf7600306
--- /dev/null
+++ b/app/javascript/initializers/lexxy_markdown_paste.js
@@ -0,0 +1,3 @@
+document.addEventListener("lexxy:insert-markdown", (event) => {
+ event.detail.addBlockSpacing()
+})
From 3959d91148a4aa1e25406d65b0f7b204ec47b12b Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Thu, 12 Mar 2026 12:24:35 -0400
Subject: [PATCH 30/33] Revert "Configure Lexxy to add extra spacing between
block elements"
This reverts commit d6bf103efd5d284518c656e11e28acfa5dab634d.
---
Gemfile | 2 +-
Gemfile.lock | 11 ++++++++---
Gemfile.saas.lock | 11 ++++++++---
app/javascript/initializers/index.js | 1 -
app/javascript/initializers/lexxy_markdown_paste.js | 3 ---
5 files changed, 17 insertions(+), 11 deletions(-)
delete mode 100644 app/javascript/initializers/lexxy_markdown_paste.js
diff --git a/Gemfile b/Gemfile
index f5e26850a..da66e1473 100644
--- a/Gemfile
+++ b/Gemfile
@@ -27,7 +27,7 @@ gem "geared_pagination", "~> 1.2"
gem "rqrcode"
gem "rouge"
gem "jbuilder"
-gem "lexxy", "0.8.0.beta"
+gem "lexxy", bc: "lexxy"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 37579ff78..8db6817dc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,10 @@
+GIT
+ remote: https://github.com/basecamp/lexxy
+ revision: b7f0f1429df5fa425eac043191ae2b65edee7488
+ specs:
+ lexxy (0.7.6.beta)
+ rails (>= 8.0.2)
+
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -254,8 +261,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- lexxy (0.8.0.beta)
- rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -518,7 +523,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy (= 0.8.0.beta)
+ lexxy!
mission_control-jobs
mittens
mocha
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index dbaaa9df4..e4ed7406e 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,6 +21,13 @@ GIT
rails (>= 7.0)
rainbow
+GIT
+ remote: https://github.com/basecamp/lexxy
+ revision: b7f0f1429df5fa425eac043191ae2b65edee7488
+ specs:
+ lexxy (0.7.6.beta)
+ rails (>= 8.0.2)
+
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 14312a940471e20617b38cdec7c092a01567d18b
@@ -368,8 +375,6 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
- lexxy (0.8.0.beta)
- rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -727,7 +732,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy (= 0.8.0.beta)
+ lexxy!
mission_control-jobs
mittens
mocha
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index d2c20451e..1d16ef205 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,4 +1,3 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
-import "initializers/lexxy_markdown_paste"
diff --git a/app/javascript/initializers/lexxy_markdown_paste.js b/app/javascript/initializers/lexxy_markdown_paste.js
deleted file mode 100644
index cf7600306..000000000
--- a/app/javascript/initializers/lexxy_markdown_paste.js
+++ /dev/null
@@ -1,3 +0,0 @@
-document.addEventListener("lexxy:insert-markdown", (event) => {
- event.detail.addBlockSpacing()
-})
From 8f6fca94fa4ac356e56be7260a3fc5e824be2c60 Mon Sep 17 00:00:00 2001
From: "Stanko K.R."
Date: Mon, 16 Mar 2026 17:50:31 +0100
Subject: [PATCH 31/33] Prevent HTML injection through filenames
---
app/javascript/controllers/upload_preview_controller.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js
index d7b0bd478..7e25d50a3 100644
--- a/app/javascript/controllers/upload_preview_controller.js
+++ b/app/javascript/controllers/upload_preview_controller.js
@@ -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)
}
From ddc6ce020e127439c0a456844140321bb3567288 Mon Sep 17 00:00:00 2001
From: "Stanko K.R."
Date: Mon, 16 Mar 2026 17:57:26 +0100
Subject: [PATCH 32/33] Conditionally disable peer verification for ZIP
streaming
---
app/models/zip_file.rb | 3 ++-
app/models/zip_file/remote_io.rb | 8 ++++++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/app/models/zip_file.rb b/app/models/zip_file.rb
index dc5b7ee8d..3415f5879 100644
--- a/app/models/zip_file.rb
+++ b/app/models/zip_file.rb
@@ -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
diff --git a/app/models/zip_file/remote_io.rb b/app/models/zip_file/remote_io.rb
index 1cb00831a..59329d811 100644
--- a/app/models/zip_file/remote_io.rb
+++ b/app/models/zip_file/remote_io.rb
@@ -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
From 964915bc665b4a8be86303200a5f643d1b41cdb2 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Tue, 17 Mar 2026 09:22:04 +0100
Subject: [PATCH 33/33] Remove payment/subscription system and card/storage
limits
Fizzy is now free. Remove the entire Stripe billing system,
subscription management, and card/storage limit enforcement.
Removes from saas/: Plan model, Account::Billing, Account::Subscription,
Account::Limited, Account::OverriddenLimits, Account::BillingWaiver,
all subscription/billing controllers and views, Stripe webhook handler,
card creation/publishing limit enforcement, admin account override UI,
usage report rake task, and all related tests.
Removes from main app: Fizzy.saas? guards for subscription panel,
SaaS card footer override, near-limit notices, and saas.css stylesheet.
Adds migration to drop billing tables from the SaaS database.
Non-billing SaaS features (push notifications, signup, authorization,
telemetry, console1984/audits1984) are preserved.
---
Gemfile.saas | 2 -
Gemfile.saas.lock | 4 -
app/views/account/settings/show.html.erb | 2 -
.../cards/container/footer/_create.html.erb | 2 -
app/views/cards/drafts/_container.html.erb | 6 +-
app/views/layouts/shared/_head.html.erb | 1 -
saas/app/assets/stylesheets/fizzy/saas.css | 70 -----
.../account/billing_portals_controller.rb | 19 --
.../subscriptions/downgrades_controller.rb | 12 -
.../subscriptions/update_plan_controller.rb | 32 ---
.../subscriptions/upgrades_controller.rb | 12 -
.../account/subscriptions_controller.rb | 46 ----
.../admin/account_searches_controller.rb | 5 -
.../controllers/admin/accounts_controller.rb | 27 --
.../admin/billing_waivers_controller.rb | 13 -
.../admin/overridden_limits_controller.rb | 8 -
.../app/controllers/admin/stats_controller.rb | 4 -
.../concerns/admin/account_scoped.rb | 12 -
saas/app/controllers/concerns/card/limited.rb | 8 -
.../concerns/card/limited_creation.rb | 11 -
.../concerns/card/limited_publishing.rb | 9 -
.../controllers/stripe/webhooks_controller.rb | 85 ------
saas/app/helpers/subscriptions_helper.rb | 19 --
.../account/sync_stripe_customer_email_job.rb | 8 -
saas/app/models/account/billing.rb | 50 ----
saas/app/models/account/billing_waiver.rb | 7 -
saas/app/models/account/limited.rb | 56 ----
saas/app/models/account/overridden_limits.rb | 4 -
saas/app/models/account/subscription.rb | 73 ------
saas/app/models/plan.rb | 59 -----
.../user/notifies_account_of_email_change.rb | 23 --
.../account/settings/_free_plan.html.erb | 18 --
.../account/settings/_paid_plan.html.erb | 21 --
.../account/settings/_subscription.html.erb | 20 --
.../settings/_subscription_panel.html.erb | 11 -
.../account/subscriptions/_upgrade.html.erb | 9 -
.../_admin_exceeding_card_limit.html.erb | 4 -
.../_admin_exceeding_storage_limit.html.erb | 4 -
.../_user_exceeding_card_limit.html.erb | 3 -
.../_user_exceeding_storage_limit.html.erb | 3 -
.../views/account/subscriptions/show.html.erb | 9 -
saas/app/views/admin/accounts/edit.html.erb | 63 -----
saas/app/views/admin/accounts/index.html.erb | 13 -
saas/app/views/admin/stats/show.html.erb | 26 --
.../container/footer/saas/_create.html.erb | 5 -
.../footer/saas/_near_notice.html.erb | 9 -
.../_admin_nearing_card_limit.html.erb | 1 -
.../_admin_nearing_storage_limit.html.erb | 1 -
.../notices/_user_nearing_card_limit.html.erb | 1 -
.../_user_nearing_storage_limit.html.erb | 1 -
saas/config/routes.rb | 5 -
.../20260317000000_drop_billing_tables.rb | 7 +
saas/db/saas_schema.rb | 34 +--
saas/exe/stripe-dev | 83 ------
saas/lib/fizzy/saas/engine.rb | 31 ---
saas/lib/tasks/fizzy/usage_report.rake | 37 ---
.../billing_portals_controller_test.rb | 29 ---
.../subscriptions/card_creation_test.rb | 73 ------
.../downgrades_controller_test.rb | 35 ---
.../accounts/subscriptions/settings_test.rb | 62 -----
.../subscriptions/upgrades_controller_test.rb | 35 ---
.../accounts/subscriptions_controller_test.rb | 60 -----
.../admin/accounts_controller_test.rb | 54 ----
.../admin/billing_waivers_controller_test.rb | 32 ---
.../overridden_limits_controller_test.rb | 22 --
.../controllers/card/limited_creation_test.rb | 55 ----
.../card/limited_publishing_test.rb | 25 --
.../stripe/webhooks_controller_test.rb | 100 -------
saas/test/fixtures/account_subscriptions.yml | 11 -
saas/test/models/account/billing_test.rb | 93 -------
saas/test/models/account/limited_test.rb | 122 ---------
saas/test/models/account/subscription_test.rb | 245 ------------------
saas/test/models/plan_test.rb | 18 --
.../notifies_account_of_email_change_test.rb | 51 ----
74 files changed, 9 insertions(+), 2221 deletions(-)
delete mode 100644 saas/app/assets/stylesheets/fizzy/saas.css
delete mode 100644 saas/app/controllers/account/billing_portals_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions/downgrades_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions/update_plan_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions/upgrades_controller.rb
delete mode 100644 saas/app/controllers/account/subscriptions_controller.rb
delete mode 100644 saas/app/controllers/admin/account_searches_controller.rb
delete mode 100644 saas/app/controllers/admin/accounts_controller.rb
delete mode 100644 saas/app/controllers/admin/billing_waivers_controller.rb
delete mode 100644 saas/app/controllers/admin/overridden_limits_controller.rb
delete mode 100644 saas/app/controllers/concerns/admin/account_scoped.rb
delete mode 100644 saas/app/controllers/concerns/card/limited.rb
delete mode 100644 saas/app/controllers/concerns/card/limited_creation.rb
delete mode 100644 saas/app/controllers/concerns/card/limited_publishing.rb
delete mode 100644 saas/app/controllers/stripe/webhooks_controller.rb
delete mode 100644 saas/app/helpers/subscriptions_helper.rb
delete mode 100644 saas/app/jobs/account/sync_stripe_customer_email_job.rb
delete mode 100644 saas/app/models/account/billing.rb
delete mode 100644 saas/app/models/account/billing_waiver.rb
delete mode 100644 saas/app/models/account/limited.rb
delete mode 100644 saas/app/models/account/overridden_limits.rb
delete mode 100644 saas/app/models/account/subscription.rb
delete mode 100644 saas/app/models/plan.rb
delete mode 100644 saas/app/models/user/notifies_account_of_email_change.rb
delete mode 100644 saas/app/views/account/settings/_free_plan.html.erb
delete mode 100644 saas/app/views/account/settings/_paid_plan.html.erb
delete mode 100644 saas/app/views/account/settings/_subscription.html.erb
delete mode 100644 saas/app/views/account/settings/_subscription_panel.html.erb
delete mode 100644 saas/app/views/account/subscriptions/_upgrade.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb
delete mode 100644 saas/app/views/account/subscriptions/show.html.erb
delete mode 100644 saas/app/views/admin/accounts/edit.html.erb
delete mode 100644 saas/app/views/admin/accounts/index.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/_create.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/_near_notice.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
delete mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
create mode 100644 saas/db/migrate/20260317000000_drop_billing_tables.rb
delete mode 100755 saas/exe/stripe-dev
delete mode 100644 saas/lib/tasks/fizzy/usage_report.rake
delete mode 100644 saas/test/controllers/accounts/billing_portals_controller_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/card_creation_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/settings_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
delete mode 100644 saas/test/controllers/accounts/subscriptions_controller_test.rb
delete mode 100644 saas/test/controllers/admin/accounts_controller_test.rb
delete mode 100644 saas/test/controllers/admin/billing_waivers_controller_test.rb
delete mode 100644 saas/test/controllers/admin/overridden_limits_controller_test.rb
delete mode 100644 saas/test/controllers/card/limited_creation_test.rb
delete mode 100644 saas/test/controllers/card/limited_publishing_test.rb
delete mode 100644 saas/test/controllers/stripe/webhooks_controller_test.rb
delete mode 100644 saas/test/fixtures/account_subscriptions.yml
delete mode 100644 saas/test/models/account/billing_test.rb
delete mode 100644 saas/test/models/account/limited_test.rb
delete mode 100644 saas/test/models/account/subscription_test.rb
delete mode 100644 saas/test/models/plan_test.rb
delete mode 100644 saas/test/models/user/notifies_account_of_email_change_test.rb
diff --git a/Gemfile.saas b/Gemfile.saas
index e57fcf206..384f5b4b7 100644
--- a/Gemfile.saas
+++ b/Gemfile.saas
@@ -5,12 +5,10 @@ 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"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
-gem "csv"
# Native push notifications (iOS/Android)
gem "action_push_native"
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index e4ed7406e..6f9b0d088 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -281,7 +281,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
- csv (3.3.5)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
@@ -632,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)
@@ -721,7 +719,6 @@ DEPENDENCIES
bundler-audit
capybara
console1984!
- csv
debug
faker
fizzy-saas!
@@ -757,7 +754,6 @@ DEPENDENCIES
sqlite3 (>= 2.0)
stackprof
stimulus-rails
- stripe (~> 18.0)
thruster
trilogy (~> 2.10)
turbo-rails!
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb
index 91bb85aff..fdb118e57 100644
--- a/app/views/account/settings/show.html.erb
+++ b/app/views/account/settings/show.html.erb
@@ -21,5 +21,3 @@
<%= render "account/settings/cancellation" %>
-
-<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb
index b55cae344..648beffd1 100644
--- a/app/views/cards/container/footer/_create.html.erb
+++ b/app/views/cards/container/footer/_create.html.erb
@@ -13,7 +13,5 @@
Create and add another
<% end %>
-
- <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %>
diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb
index fa7134b55..9ffc04711 100644
--- a/app/views/cards/drafts/_container.html.erb
+++ b/app/views/cards/drafts/_container.html.erb
@@ -26,9 +26,5 @@
<% 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 %>
diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb
index 4be536454..9ef153df2 100644
--- a/app/views/layouts/shared/_head.html.erb
+++ b/app/views/layouts/shared/_head.html.erb
@@ -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 %>
diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css
deleted file mode 100644
index a3fedc53d..000000000
--- a/saas/app/assets/stylesheets/fizzy/saas.css
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/saas/app/controllers/account/billing_portals_controller.rb b/saas/app/controllers/account/billing_portals_controller.rb
deleted file mode 100644
index 67eaffe6c..000000000
--- a/saas/app/controllers/account/billing_portals_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb
deleted file mode 100644
index d06795841..000000000
--- a/saas/app/controllers/account/subscriptions/downgrades_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb
deleted file mode 100644
index c6a665cec..000000000
--- a/saas/app/controllers/account/subscriptions/update_plan_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb
deleted file mode 100644
index 1de6d8c6c..000000000
--- a/saas/app/controllers/account/subscriptions/upgrades_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb
deleted file mode 100644
index bbc4ec47a..000000000
--- a/saas/app/controllers/account/subscriptions_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/admin/account_searches_controller.rb b/saas/app/controllers/admin/account_searches_controller.rb
deleted file mode 100644
index db655a9ef..000000000
--- a/saas/app/controllers/admin/account_searches_controller.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class Admin::AccountSearchesController < AdminController
- def create
- redirect_to saas.edit_admin_account_path(params[:q])
- end
-end
diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb
deleted file mode 100644
index 26a2b9c82..000000000
--- a/saas/app/controllers/admin/accounts_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb
deleted file mode 100644
index 713891069..000000000
--- a/saas/app/controllers/admin/billing_waivers_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/admin/overridden_limits_controller.rb b/saas/app/controllers/admin/overridden_limits_controller.rb
deleted file mode 100644
index fff8ea3e3..000000000
--- a/saas/app/controllers/admin/overridden_limits_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb
index 287b49024..6d508fc2c 100644
--- a/saas/app/controllers/admin/stats_controller.rb
+++ b/saas/app/controllers/admin/stats_controller.rb
@@ -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 = Account::Subscription.paid.distinct.count(:account_id)
- @paid_accounts_last_7_days = Account::Subscription.paid.where(created_at: 7.days.ago..).distinct.count(:account_id)
- @paid_accounts_last_24_hours = Account::Subscription.paid.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
diff --git a/saas/app/controllers/concerns/admin/account_scoped.rb b/saas/app/controllers/concerns/admin/account_scoped.rb
deleted file mode 100644
index 314720cd2..000000000
--- a/saas/app/controllers/concerns/admin/account_scoped.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb
deleted file mode 100644
index 0576c6c4e..000000000
--- a/saas/app/controllers/concerns/card/limited.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-module Card::Limited
- extend ActiveSupport::Concern
-
- private
- def ensure_under_limits
- head :forbidden if Current.account.exceeding_limits?
- end
-end
diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb
deleted file mode 100644
index 0e992008a..000000000
--- a/saas/app/controllers/concerns/card/limited_creation.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb
deleted file mode 100644
index 8b60a4b53..000000000
--- a/saas/app/controllers/concerns/card/limited_publishing.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb
deleted file mode 100644
index 13dcd4699..000000000
--- a/saas/app/controllers/stripe/webhooks_controller.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb
deleted file mode 100644
index fd6b5aa47..000000000
--- a/saas/app/helpers/subscriptions_helper.rb
+++ /dev/null
@@ -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 #{ format_currency(subscription.next_amount_due) } on".html_safe
- end
- end
-
- def format_currency(amount)
- number_to_currency(amount, precision: (amount % 1).zero? ? 0 : 2)
- end
-end
diff --git a/saas/app/jobs/account/sync_stripe_customer_email_job.rb b/saas/app/jobs/account/sync_stripe_customer_email_job.rb
deleted file mode 100644
index b84eafa76..000000000
--- a/saas/app/jobs/account/sync_stripe_customer_email_job.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb
deleted file mode 100644
index c124fd64c..000000000
--- a/saas/app/models/account/billing.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb
deleted file mode 100644
index a9ec35eb8..000000000
--- a/saas/app/models/account/billing_waiver.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb
deleted file mode 100644
index 2918e90a3..000000000
--- a/saas/app/models/account/limited.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/models/account/overridden_limits.rb b/saas/app/models/account/overridden_limits.rb
deleted file mode 100644
index cdf3c51ec..000000000
--- a/saas/app/models/account/overridden_limits.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-# To ease testing of limits
-class Account::OverriddenLimits < SaasRecord
- belongs_to :account
-end
diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb
deleted file mode 100644
index c1ec9e47c..000000000
--- a/saas/app/models/account/subscription.rb
+++ /dev/null
@@ -1,73 +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)
-
- scope :paid, -> {
- where(status: %w[active trialing past_due])
- .where(plan_key: Plan.all.select(&:paid?).map(&:key))
- .where.not(account_id: Account::BillingWaiver.select(:account_id))
- }
-
- 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
diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb
deleted file mode 100644
index e77ea81e8..000000000
--- a/saas/app/models/plan.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/models/user/notifies_account_of_email_change.rb b/saas/app/models/user/notifies_account_of_email_change.rb
deleted file mode 100644
index daba4ba3d..000000000
--- a/saas/app/models/user/notifies_account_of_email_change.rb
+++ /dev/null
@@ -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
diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb
deleted file mode 100644
index ca467d8af..000000000
--- a/saas/app/views/account/settings/_free_plan.html.erb
+++ /dev/null
@@ -1,18 +0,0 @@
-
- <% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %>
- You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage
- <% elsif Current.account.exceeding_card_limit? %>
- You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards
- <% elsif Current.account.exceeding_storage_limit? %>
- You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage
- <% else %>
- You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
- <% end %>
-
-
-To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= format_currency(Plan.paid.price) %>/month for unlimited cards , unlimited users , and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.
-
-<%= 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 } } %>
-
-Cancel anytime, no contracts, take your data with you whenever.
-
diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb
deleted file mode 100644
index 6ea99a066..000000000
--- a/saas/app/views/account/settings/_paid_plan.html.erb
+++ /dev/null
@@ -1,21 +0,0 @@
-
- <% if Current.account.exceeding_storage_limit? %>
- You’ve run out of storage
- <% elsif Current.account.nearing_plan_storage_limit? %>
- You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> of storage
- <% else %>
- Thank you for buying Fizzy
- <% end %>
-
-
-<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>
-
- The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %> . Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
-
-
- <%= 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 %>
diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb
deleted file mode 100644
index 4eca0ad2d..000000000
--- a/saas/app/views/account/settings/_subscription.html.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-<% if subscription.current_period_end %>
- <%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>
-<% end %>
-
-
- <%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>
-
-
-
-
diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb
deleted file mode 100644
index 1cfcda4d8..000000000
--- a/saas/app/views/account/settings/_subscription_panel.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-<% if Current.user.admin? && !Current.account.comped? %>
-
- Subscription
-
- <% if Current.account.plan.free? %>
- <%= render "account/settings/free_plan" %>
- <% else %>
- <%= render "account/settings/paid_plan" %>
- <% end %>
-
-<% end %>
diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb
deleted file mode 100644
index 449fecf16..000000000
--- a/saas/app/views/account/subscriptions/_upgrade.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-
- <% 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 %>
-
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb
deleted file mode 100644
index 3dc759d9e..000000000
--- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-You’ve used your <%= Plan.free.card_limit %> free cards.
-<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
- Upgrade to Unlimited
-<% end %>
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb
deleted file mode 100644
index 8770c1460..000000000
--- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>.
-<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %>
- Upgrade to get more
-<% end %>
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb
deleted file mode 100644
index f3f65f3be..000000000
--- a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more.
-
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb
deleted file mode 100644
index f230443a1..000000000
--- a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.
-
diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb
deleted file mode 100644
index 8d29a379e..000000000
--- a/saas/app/views/account/subscriptions/show.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-<% @page_title = "Thank you" %>
-
-<% if @stripe_session&.payment_status == "paid" %>
-
- Thanks for buying Fizzy!
- Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.
- <%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %>
-
-<% end %>
diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb
deleted file mode 100644
index 76c23497c..000000000
--- a/saas/app/views/admin/accounts/edit.html.erb
+++ /dev/null
@@ -1,63 +0,0 @@
-<% @page_title = "Account admin" %>
-
-<% content_for :header do %>
-
-<% end %>
-
-
-
-
-
Edit Account <%= @account.external_account_id %>
-
- <%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
-
-
- Account Name:
- <%= @account.name %>
-
-
- Subscription plan:
- <%= @account.plan.name %>
-
-
- Actual card count:
- <%= @account.cards_count %> cards
-
-
- Actual bytes used:
- <%= storage_to_human_size(@account.bytes_used) %>
-
-
-
- <%= form.label :card_count, class: "txt-subtle" do %>
- Override card count <%= "(Exceeds plan limit)" if @account.exceeding_card_limit? %>
- <% end %>
-
- <%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %>
-
-
-
- <%= form.label :bytes_used, class: "txt-subtle" do %>
- Override bytes used <%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %>
- <% end %>
-
- <%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
-
-
-
-
Save changes
- <% 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 %>
-
-
diff --git a/saas/app/views/admin/accounts/index.html.erb b/saas/app/views/admin/accounts/index.html.erb
deleted file mode 100644
index 96bed2338..000000000
--- a/saas/app/views/admin/accounts/index.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
Find an account
-
- <%= 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 %>
-
-
-
-
-
diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb
index f93bd3eba..c2b1e1d5d 100644
--- a/saas/app/views/admin/stats/show.html.erb
+++ b/saas/app/views/admin/stats/show.html.erb
@@ -33,32 +33,6 @@
-
-
-
-
-
Total
-
- <%= @paid_accounts_total %>
-
-
-
-
7 days
-
- <%= @paid_accounts_last_7_days %>
-
-
-
-
24 hours
-
- <%= @paid_accounts_last_24_hours %>
-
-
-
-
-
Identities Created
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb
deleted file mode 100644
index 8b0edd308..000000000
--- a/saas/app/views/cards/container/footer/saas/_create.html.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<% if Current.account.exceeding_limits? %>
- <%= render "account/subscriptions/upgrade" %>
-<% else %>
- <%= render "cards/container/footer/create", card: card %>
-<% end %>
diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb
deleted file mode 100644
index 4cf480f3c..000000000
--- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
-
- <% 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 %>
-
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
deleted file mode 100644
index caa7bd958..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %> .
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
deleted file mode 100644
index ab1a3ca34..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
+++ /dev/null
@@ -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" %>. <%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %> .
diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
deleted file mode 100644
index 8ab80e162..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon.
diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
deleted file mode 100644
index 25428980a..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
+++ /dev/null
@@ -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.
diff --git a/saas/config/routes.rb b/saas/config/routes.rb
index 581a2ae02..3eafab92f 100644
--- a/saas/config/routes.rb
+++ b/saas/config/routes.rb
@@ -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
diff --git a/saas/db/migrate/20260317000000_drop_billing_tables.rb b/saas/db/migrate/20260317000000_drop_billing_tables.rb
new file mode 100644
index 000000000..2ac9a808d
--- /dev/null
+++ b/saas/db/migrate/20260317000000_drop_billing_tables.rb
@@ -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
diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb
index 298cc033a..5ae786764 100644
--- a/saas/db/saas_schema.rb
+++ b/saas/db/saas_schema.rb
@@ -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"
diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev
deleted file mode 100755
index d3e63a754..000000000
--- a/saas/exe/stripe-dev
+++ /dev/null
@@ -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}"
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index 1ba45df42..dea321cfc 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -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)
diff --git a/saas/lib/tasks/fizzy/usage_report.rake b/saas/lib/tasks/fizzy/usage_report.rake
deleted file mode 100644
index d8b3ba108..000000000
--- a/saas/lib/tasks/fizzy/usage_report.rake
+++ /dev/null
@@ -1,37 +0,0 @@
-require "csv"
-
-namespace :saas do
- desc "Generate a CSV usage report for all active accounts"
- task usage_report: :environment do
- output_path = Rails.root.join("tmp/usage_report.csv")
-
- CSV.open(output_path, "w") do |csv|
- csv << [ "Queenbee ID", "Account Name", "Sign Up Date", "Paid Date", "Comped", "Card Count", "Storage Used (Bytes)", "Last Active" ]
-
- Account.active.includes(:storage_total).in_batches do |batch|
- batch_ids = batch.pluck(:id)
- paid_dates = Account::Subscription.paid.where(account_id: batch_ids)
- .group(:account_id).minimum(:created_at)
- comped_account_ids = Account::BillingWaiver.where(account_id: batch_ids)
- .pluck(:account_id).to_set
- last_active_dates = Card.where(account_id: batch_ids)
- .group(:account_id).maximum(:last_active_at)
-
- batch.each do |account|
- csv << [
- account.external_account_id,
- account.name,
- account.created_at.to_date,
- paid_dates[account.id]&.to_date,
- comped_account_ids.include?(account.id),
- account.cards_count,
- account.bytes_used,
- last_active_dates[account.id]&.to_date
- ]
- end
- end
- end
-
- puts "Report written to #{output_path}"
- end
-end
diff --git a/saas/test/controllers/accounts/billing_portals_controller_test.rb b/saas/test/controllers/accounts/billing_portals_controller_test.rb
deleted file mode 100644
index 00f16e4df..000000000
--- a/saas/test/controllers/accounts/billing_portals_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb
deleted file mode 100644
index 1ddc4bc1c..000000000
--- a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
deleted file mode 100644
index e4680a1f8..000000000
--- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb
deleted file mode 100644
index 63feac2c5..000000000
--- a/saas/test/controllers/accounts/subscriptions/settings_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
deleted file mode 100644
index 65e268af1..000000000
--- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb
deleted file mode 100644
index 9e086df39..000000000
--- a/saas/test/controllers/accounts/subscriptions_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb
deleted file mode 100644
index 3849c07a5..000000000
--- a/saas/test/controllers/admin/accounts_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb
deleted file mode 100644
index 8f7d14199..000000000
--- a/saas/test/controllers/admin/billing_waivers_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/admin/overridden_limits_controller_test.rb b/saas/test/controllers/admin/overridden_limits_controller_test.rb
deleted file mode 100644
index b660e08b0..000000000
--- a/saas/test/controllers/admin/overridden_limits_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb
deleted file mode 100644
index 4105091be..000000000
--- a/saas/test/controllers/card/limited_creation_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb
deleted file mode 100644
index 90e4e5696..000000000
--- a/saas/test/controllers/card/limited_publishing_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb
deleted file mode 100644
index 92f6b7508..000000000
--- a/saas/test/controllers/stripe/webhooks_controller_test.rb
+++ /dev/null
@@ -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
diff --git a/saas/test/fixtures/account_subscriptions.yml b/saas/test/fixtures/account_subscriptions.yml
deleted file mode 100644
index a18ff961b..000000000
--- a/saas/test/fixtures/account_subscriptions.yml
+++ /dev/null
@@ -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 %>
diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb
deleted file mode 100644
index 0e70d37a5..000000000
--- a/saas/test/models/account/billing_test.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-require "test_helper"
-
-class Account::BillingTest < ActiveSupport::TestCase
- test "plan reflects active subscription" do
- account = accounts(:initech)
-
- # No subscription
- assert_equal Plan.free, account.plan
-
- # Subscription but it is not active
- account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test")
- assert_equal Plan.free, account.plan
-
- # Active subscription exists
- account.subscription.update!(status: "active")
- assert_equal Plan.paid, account.plan
- end
-
- test "comped account" do
- account = accounts(:"37s")
-
- assert_not account.comped?
-
- account.comp
- assert account.comped?
-
- # Calling comp again does not create duplicate
- account.comp
- assert_equal 1, Account::BillingWaiver.where(account: account).count
- end
-
- test "cancel callback pauses subscription" do
- account = accounts(:"37s")
- user = users(:david)
-
- subscription = mock("subscription")
- subscription.expects(:pause).once
- account.stubs(:subscription).returns(subscription)
-
- account.cancel(initiated_by: user)
- end
-
- test "reactivate callback resumes subscription" do
- account = accounts(:"37s")
- user = users(:david)
-
- # First cancel with a subscription mock
- subscription_for_cancel = mock("subscription")
- subscription_for_cancel.expects(:pause).once
- account.stubs(:subscription).returns(subscription_for_cancel)
-
- account.cancel(initiated_by: user)
-
- # Now stub for reactivation
- subscription_for_reactivate = mock("subscription")
- subscription_for_reactivate.expects(:resume).once
- account.stubs(:subscription).returns(subscription_for_reactivate)
-
- account.reactivate
- end
-
- test "incinerate callback cancels subscription before destroying account" do
- account = accounts(:"37s")
-
- subscription = mock("subscription")
- subscription.expects(:cancel).once
- account.stubs(:subscription).returns(subscription)
-
- account.incinerate
- end
-
- test "owner_email_changed enqueues sync job when subscription exists" do
- account = accounts(:"37s")
- account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- assert_enqueued_with(job: Account::SyncStripeCustomerEmailJob, args: [ account.subscription ]) do
- account.owner_email_changed
- end
- end
-
- test "owner_email_changed does nothing without subscription" do
- account = accounts(:initech)
- account.subscription&.destroy
-
- assert_no_enqueued_jobs do
- account.owner_email_changed
- end
- end
-end
diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb
deleted file mode 100644
index fff154451..000000000
--- a/saas/test/models/account/limited_test.rb
+++ /dev/null
@@ -1,122 +0,0 @@
-require "test_helper"
-
-class Account::LimitedTest < ActiveSupport::TestCase
- test "detect nearing card limit" do
- # Paid plans are never limited
- accounts(:"37s").update_column(:cards_count, 1_000_000)
- assert_not accounts(:"37s").nearing_plan_cards_limit?
-
- # Free plan not near limit
- accounts(:initech).update_column(:cards_count, 899)
- assert_not accounts(:initech).nearing_plan_cards_limit?
-
- # Free plan at exactly the threshold (remaining = 100)
- accounts(:initech).update_column(:cards_count, 900)
- assert accounts(:initech).nearing_plan_cards_limit?
-
- # Free plan over the threshold
- accounts(:initech).update_column(:cards_count, 901)
- assert accounts(:initech).nearing_plan_cards_limit?
- end
-
- test "detect exceeding card limit" do
- # Paid plans are never limited
- accounts(:"37s").update_column(:cards_count, 1_000_000)
- assert_not accounts(:"37s").exceeding_card_limit?
-
- # Free plan under limit
- accounts(:initech).update_column(:cards_count, 999)
- assert_not accounts(:initech).exceeding_card_limit?
-
- # Free plan at exactly the limit
- accounts(:initech).update_column(:cards_count, 1000)
- assert accounts(:initech).exceeding_card_limit?
-
- # Free plan over limit
- accounts(:initech).update_column(:cards_count, 1001)
- assert accounts(:initech).exceeding_card_limit?
- end
-
- test "override limits" do
- account = accounts(:initech)
- account.update_column(:cards_count, 1001)
-
- assert account.exceeding_card_limit?
- assert_equal 1001, account.billed_cards_count
-
- account.override_limits card_count: 500
- assert_not account.exceeding_card_limit?
- assert_equal 500, account.billed_cards_count
- assert_equal 1001, account.cards_count # original unchanged
-
- account.reset_overridden_limits
- assert account.exceeding_card_limit?
- assert_equal 1001, account.billed_cards_count
- end
-
- test "comped accounts are never limited" do
- account = accounts(:initech)
- account.update_column(:cards_count, 1_000_000)
-
- assert account.exceeding_card_limit?
- assert account.nearing_plan_cards_limit?
-
- account.comp
-
- assert_not account.exceeding_card_limit?
- assert_not account.nearing_plan_cards_limit?
- end
-
- test "uncomping an account restores limits" do
- account = accounts(:initech)
- account.update_column(:cards_count, 1_000_000)
- account.comp
-
- assert_not account.exceeding_card_limit?
-
- account.uncomp
-
- assert account.exceeding_card_limit?
- end
-
- test "detect nearing storage limit" do
- # Paid plans have large storage limits
- accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes)
- assert_not accounts(:"37s").nearing_plan_storage_limit?
-
- # Free plan not near limit
- accounts(:initech).stubs(:bytes_used).returns(400.megabytes)
- assert_not accounts(:initech).nearing_plan_storage_limit?
-
- # Free plan near limit
- accounts(:initech).stubs(:bytes_used).returns(600.megabytes)
- assert accounts(:initech).nearing_plan_storage_limit?
- end
-
- test "detect exceeding storage limit" do
- # Free plan under limit
- accounts(:initech).stubs(:bytes_used).returns(900.megabytes)
- assert_not accounts(:initech).exceeding_storage_limit?
-
- # Free plan over limit
- accounts(:initech).stubs(:bytes_used).returns(1.1.gigabytes)
- assert accounts(:initech).exceeding_storage_limit?
- end
-
- test "override bytes_used limits" do
- account = accounts(:initech)
- account.stubs(:bytes_used).returns(1.1.gigabytes)
-
- assert account.exceeding_storage_limit?
- assert_equal 1.1.gigabytes, account.billed_bytes_used
-
- account.override_limits bytes_used: 500.megabytes
- assert_not account.exceeding_storage_limit?
- assert_equal 500.megabytes, account.billed_bytes_used
- assert_equal 1.1.gigabytes, account.bytes_used # original unchanged
-
- account.reset_overridden_limits
- assert account.exceeding_storage_limit?
- assert_equal 1.1.gigabytes, account.billed_bytes_used
- end
-end
diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb
deleted file mode 100644
index f9f16f2bb..000000000
--- a/saas/test/models/account/subscription_test.rb
+++ /dev/null
@@ -1,245 +0,0 @@
-require "test_helper"
-
-class Account::SubscriptionTest < ActiveSupport::TestCase
- test "get the account plan" do
- subscription = Account::Subscription.new(plan_key: "free_v1")
- assert_equal Plan[:free_v1], subscription.plan
- end
-
- test "check if account is active" do
- subscription = Account::Subscription.new(status: "active")
- assert subscription.active?
- end
-
- test "check if account is paid" do
- assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid?
- assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
- end
-
- test "paid scope includes active paid subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "active",
- stripe_customer_id: "cus_paid"
- )
-
- assert_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope includes trialing and past_due subscriptions" do
- trialing = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "trialing",
- stripe_customer_id: "cus_trialing"
- )
- past_due = Account::Subscription.create!(
- account: accounts(:acme), plan_key: "monthly_extra_storage_v1", status: "past_due",
- stripe_customer_id: "cus_past_due"
- )
-
- assert_includes Account::Subscription.paid, trialing
- assert_includes Account::Subscription.paid, past_due
- end
-
- test "paid scope excludes free plan subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "free_v1", status: "active",
- stripe_customer_id: "cus_free"
- )
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope excludes canceled subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "canceled",
- stripe_customer_id: "cus_canceled"
- )
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope excludes comped accounts" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "active",
- stripe_customer_id: "cus_comped"
- )
- Account::BillingWaiver.create!(account: accounts(:initech))
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "pause pauses Stripe subscription with void behavior" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.expects(:update).with(
- "sub_123",
- pause_collection: { behavior: "void" }
- ).returns(true)
-
- subscription.pause
- end
-
- test "pause does nothing when no stripe_subscription_id" do
- subscription = Account::Subscription.new(stripe_subscription_id: nil)
-
- Stripe::Subscription.expects(:update).never
-
- subscription.pause
- end
-
- test "pause raises on Stripe errors" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.stubs(:update).raises(
- Stripe::APIConnectionError.new("Network error")
- )
-
- assert_raises Stripe::APIConnectionError do
- subscription.pause
- end
- end
-
- test "resume resumes Stripe subscription" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.expects(:update).with(
- "sub_123",
- pause_collection: ""
- ).returns(true)
-
- subscription.resume
- end
-
- test "resume does nothing when no stripe_subscription_id" do
- subscription = Account::Subscription.new(stripe_subscription_id: nil)
-
- Stripe::Subscription.expects(:update).never
-
- subscription.resume
- end
-
- test "resume raises on Stripe errors" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.stubs(:update).raises(
- Stripe::AuthenticationError.new("Invalid API key")
- )
-
- assert_raises Stripe::AuthenticationError do
- subscription.resume
- end
- end
-
- test "cancel cancels Stripe subscription" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.expects(:cancel).with("sub_123").returns(true)
-
- subscription.cancel
- end
-
- test "cancel does nothing when no stripe_subscription_id" do
- subscription = Account::Subscription.new(stripe_subscription_id: nil)
-
- Stripe::Subscription.expects(:cancel).never
-
- subscription.cancel
- end
-
- test "cancel treats 404 as success" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_deleted")
-
- Stripe::Subscription.stubs(:cancel).raises(
- Stripe::InvalidRequestError.new("No such subscription", {})
- )
-
- assert_nothing_raised do
- subscription.cancel
- end
- end
-
- test "cancel raises on other Stripe errors" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.stubs(:cancel).raises(
- Stripe::RateLimitError.new("Rate limit exceeded")
- )
-
- assert_raises Stripe::RateLimitError do
- subscription.cancel
- end
- end
-
- test "sync_customer_email_to_stripe updates Stripe customer with owner email" do
- account = accounts(:"37s")
- owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).with("cus_test", email: owner.identity.email_address).once
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing without stripe_customer_id" do
- account = accounts(:"37s")
- subscription = account.build_subscription(
- stripe_customer_id: nil,
- plan_key: "free_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing without owner" do
- account = accounts(:"37s")
- account.users.update_all(role: :member)
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing when owner has no identity" do
- account = accounts(:"37s")
- owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- owner.update_column(:identity_id, nil)
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe treats deleted customer as success" do
- account = accounts(:"37s")
- account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_deleted",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.stubs(:update).raises(
- Stripe::InvalidRequestError.new("No such customer", {})
- )
-
- assert_nothing_raised do
- subscription.sync_customer_email_to_stripe
- end
- end
-end
diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb
deleted file mode 100644
index 54ed279d6..000000000
--- a/saas/test/models/plan_test.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-require "test_helper"
-
-class PlanTest < ActiveSupport::TestCase
- test "free plan is free" do
- assert Plan[:free_v1].free?
- end
-
- test "monthly plan is not free" do
- assert_not Plan[:monthly_v1].free?
- end
-
- test "find plan by its price id" do
- Plan.paid.stubs(:stripe_price_id).returns("price_monthly_v1")
-
- assert_equal Plan.paid, Plan.find_by_price_id("price_monthly_v1")
- assert_nil Plan.find_by_price_id("unknown_price_id")
- end
-end
diff --git a/saas/test/models/user/notifies_account_of_email_change_test.rb b/saas/test/models/user/notifies_account_of_email_change_test.rb
deleted file mode 100644
index 442a89fff..000000000
--- a/saas/test/models/user/notifies_account_of_email_change_test.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require "test_helper"
-
-class User::NotifiesAccountOfEmailChangeTest < ActiveSupport::TestCase
- setup do
- @account = accounts(:"37s")
- @owner = @account.users.find_by(role: :owner) || @account.users.first.tap { |u| u.update!(role: :owner) }
- @member = @account.users.where.not(id: @owner.id).first || @account.users.create!(
- name: "Member",
- identity: Identity.create!(email_address: "member@example.com"),
- role: :member
- )
- end
-
- test "notifies account when owner changes email" do
- @account.expects(:owner_email_changed).once
-
- new_identity = Identity.create!(email_address: "new-owner@example.com")
- @owner.update!(identity: new_identity)
- end
-
- test "does not notify account when non-owner changes email" do
- @account.expects(:owner_email_changed).never
-
- new_identity = Identity.create!(email_address: "new-member@example.com")
- @member.update!(identity: new_identity)
- end
-
- test "does not notify account when owner is deactivated" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(identity: nil)
- end
-
- test "does not notify account when identity unchanged" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(name: "New Name")
- end
-
- test "notifies account when user becomes owner" do
- @account.expects(:owner_email_changed).once
-
- @member.update!(role: :owner)
- end
-
- test "does not notify account when owner becomes member" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(role: :member)
- end
-end