-
- <%= 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/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 }) %>
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/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb
index d1251b7c0..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", data: { turbo: false } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
Sign out
<% end %>
<% end %>
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..41fac1446
--- /dev/null
+++ b/app/views/pwa/service_worker.js.erb
@@ -0,0 +1,97 @@
+importScripts("<%= javascript_url("turbo-offline-umd.min") %>")
+
+// Documents - top-level navigation requests only
+// We need `cache: "no-cache"` here to work around
+// an annoying Safari PWA bug that predates offline mode
+TurboOffline.addRule({
+ match: (request) => request.destination === "document",
+ except: /\/(edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 3,
+ fetchOptions: { cache: "no-cache" },
+ maxEntrySize: 1024 * 1024
+ })
+})
+
+TurboOffline.addRule({
+ match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/,
+ handler: TurboOffline.handlers.cacheFirst({
+ cacheName: "assets",
+ maxAge: 60 * 60 * 24 * 7,
+ maxEntrySize: 1024 * 1024
+ })
+})
+
+TurboOffline.addRule({
+ match: /\/(boards|cards|users)\//,
+ except: /\/(edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24 * 3,
+ networkTimeout: 2,
+ maxEntrySize: 1024 * 1024
+ })
+})
+
+TurboOffline.addRule({
+ match: /\/rails\/active_storage\//,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "storage",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 2,
+ maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs
+ maxEntries: 500,
+ fetchOptions: { mode: "cors" }
+ })
+})
+
+// Everything else
+TurboOffline.addRule({
+ except: /\/(service-worker\.js|edit|pin|watch|new)$/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "main",
+ maxAge: 60 * 60 * 24,
+ networkTimeout: 3,
+ maxEntrySize: 1024 * 1024
+ })
+})
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+TurboOffline.start()
+
+
+self.addEventListener("push", (event) => {
+ const data = event.data.json()
+ event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
+})
+
+async function showNotification({ title, options }) {
+ return self.registration.showNotification(title, options)
+}
+
+async function updateBadgeCount({ data: { badge } }) {
+ return self.navigator.setAppBadge?.(badge || 0)
+}
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close()
+
+ const url = new URL(event.notification.data.url, self.location.origin).href
+ event.waitUntil(openURL(url))
+})
+
+async function openURL(url) {
+ const clients = await self.clients.matchAll({ type: "window" })
+ const focused = clients.find((client) => client.focused)
+
+ if (focused) {
+ await focused.navigate(url)
+ } else {
+ await self.clients.openWindow(url)
+ }
+}
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 58068d2d6..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", data: { turbo: false } do %>
+ <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
Sign out of Fizzy on this device
<% end %>
<% end %>
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"
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/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 6b9592fca..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 = paid_subscriptions.distinct.count(:account_id)
- @paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id)
- @paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id)
-
@identities_total = Identity.count
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
@identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count
@@ -21,12 +17,4 @@ class Admin::StatsController < AdminController
@recent_accounts = Account.order(created_at: :desc).limit(10)
end
-
- private
- def paid_subscriptions
- Account::Subscription
- .where(status: %w[active trialing past_due])
- .where(plan_key: %w[monthly_v1 monthly_extra_storage_v1])
- .where.not(account_id: Account::BillingWaiver.select(:account_id))
- end
end
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 2ae7e3171..000000000
--- a/saas/app/models/account/subscription.rb
+++ /dev/null
@@ -1,67 +0,0 @@
-class Account::Subscription < SaasRecord
- belongs_to :account
-
- enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
-
- validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
-
- delegate :paid?, to: :plan
-
- def plan
- @plan ||= Plan.find(plan_key)
- end
-
- def plan=(plan)
- self.plan_key = plan.key
- end
-
- def to_be_canceled?
- active? && cancel_at.present?
- end
-
- def next_amount_due
- next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price
- end
-
- def pause
- if stripe_subscription_id.present?
- Stripe::Subscription.update(
- stripe_subscription_id,
- pause_collection: { behavior: "void" }
- )
- end
- end
-
- def resume
- if stripe_subscription_id.present?
- Stripe::Subscription.update(
- stripe_subscription_id,
- pause_collection: ""
- )
- end
- end
-
- def cancel
- Stripe::Subscription.cancel(stripe_subscription_id) if stripe_subscription_id.present?
- rescue Stripe::InvalidRequestError => e
- # Subscription already deleted/canceled in Stripe - treat as success
- Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
- end
-
- def sync_customer_email_to_stripe
- if stripe_customer_id && (email = owner_email)
- Stripe::Customer.update(stripe_customer_id, email: email)
- end
- rescue Stripe::InvalidRequestError => e
- # Customer already deleted in Stripe - treat as success
- Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}"
- end
-
- private
- # Account owner email for Stripe customer record. Returns nil when:
- # - No owner exists (ownership being transferred, account in limbo)
- # - Owner has no identity (deactivated user)
- def owner_email
- account.users.owner.first&.identity&.email_address
- end
-end
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.
Cancel anytime, no contracts, take your data with you whenever.
-
Right now you’re on the <%= Current.account.plan.name %> plan which includes <%= number_with_delimiter(Plan.free.card_limit) %> cards, unlimited users, and <%= storage_to_human_size(Plan.free.storage_limit) %> of storage.
- <% 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 %>
-
- <%= form.label :bytes_used, class: "txt-subtle" do %>
- Override bytes used <%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %>
- <% end %>
-