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 %> -
- <%= back_link_to "Accounts", saas.admin_accounts_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> -
-<% 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" %>
- - - <% 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 @@ -
-
-

Accounts Subscribed

-
-
-
-
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