From ccc69268d2a5b14454003c68c1a3494c57efb9ff Mon Sep 17 00:00:00 2001
From: "Philip I. Thomas"
Date: Wed, 11 Mar 2026 21:02:17 -0700
Subject: [PATCH 1/2] Require payment for hosted (multi-user) Postcard
Make hosted Postcard paid-only ($4/mo with 30-day free trial) while
keeping solo/self-hosted completely free. Existing free accounts are
grandfathered and canceled accounts stay active (no lockout).
- Add grandfathered boolean to accounts, backfill existing as true
- Add requires_payment? and ever_subscribed? to Account model
- Add 30-day trial to Stripe checkout session
- Redirect new signups and unpaid returning users to Stripe checkout
- Add PaymentRequired concern to dashboard controllers
- Replace two-tier pricing page with single $4/mo plan + free trial
- Update CTAs to reference free trial
- Update philipithomas.com URLs to philipithomas.postcard.page
- Add test infrastructure and model tests
Co-Authored-By: Claude Opus 4.6
---
.../accounts/omniauth_callbacks_controller.rb | 6 +-
.../accounts/registrations_controller.rb | 7 ++-
.../accounts/sessions_controller.rb | 6 +-
app/controllers/checkout_controller.rb | 2 +-
app/controllers/concerns/payment_required.rb | 18 ++++++
app/controllers/pages_controller.rb | 1 +
app/controllers/posts_controller.rb | 1 +
app/controllers/showcase_controller.rb | 1 +
app/controllers/subscribers_controller.rb | 1 +
app/models/account.rb | 15 +++++
app/views/layouts/dashboard.html.erb | 2 +-
app/views/marketing_pages/_cta.html.erb | 2 +-
app/views/marketing_pages/_hero.html.erb | 2 +-
app/views/marketing_pages/_pricing.html.erb | 59 +++++--------------
.../how_i_replaced_twitter.html.erb | 4 +-
.../social_networks_over.html.erb | 2 +-
...312034422_add_grandfathered_to_accounts.rb | 12 ++++
db/schema.rb | 3 +-
test/fixtures/accounts.yml | 15 +++++
test/models/account_test.rb | 50 ++++++++++++++++
test/test_helper.rb | 9 +++
21 files changed, 161 insertions(+), 57 deletions(-)
create mode 100644 app/controllers/concerns/payment_required.rb
create mode 100644 db/migrate/20260312034422_add_grandfathered_to_accounts.rb
create mode 100644 test/fixtures/accounts.yml
create mode 100644 test/models/account_test.rb
create mode 100644 test/test_helper.rb
diff --git a/app/controllers/accounts/omniauth_callbacks_controller.rb b/app/controllers/accounts/omniauth_callbacks_controller.rb
index ca7c9e2..18401fd 100644
--- a/app/controllers/accounts/omniauth_callbacks_controller.rb
+++ b/app/controllers/accounts/omniauth_callbacks_controller.rb
@@ -17,7 +17,11 @@ def google_oauth2
end
def after_sign_in_path_for(resource_or_scope)
- stored_location_for(resource_or_scope) || root_path
+ stored = stored_location_for(resource_or_scope)
+ return stored if stored.present?
+ return page_checkout_path(resource_or_scope) if resource_or_scope.is_a?(Account) && resource_or_scope.requires_payment?
+
+ root_path
end
private
diff --git a/app/controllers/accounts/registrations_controller.rb b/app/controllers/accounts/registrations_controller.rb
index 254181a..c9ccf51 100644
--- a/app/controllers/accounts/registrations_controller.rb
+++ b/app/controllers/accounts/registrations_controller.rb
@@ -35,8 +35,11 @@ def create
end
def after_sign_up_path_for(account)
- # page_setup_index_path(account)
- page_path(account)
+ if Rails.configuration.multiuser_mode
+ page_checkout_path(account)
+ else
+ page_path(account)
+ end
end
def update_resource(resource, params)
diff --git a/app/controllers/accounts/sessions_controller.rb b/app/controllers/accounts/sessions_controller.rb
index cf6b963..1bb18e6 100644
--- a/app/controllers/accounts/sessions_controller.rb
+++ b/app/controllers/accounts/sessions_controller.rb
@@ -27,7 +27,11 @@ class SessionsController < Devise::SessionsController
# end
def after_sign_in_path_for(account)
- stored_location_for(account) || root_path
+ stored = stored_location_for(account)
+ return stored if stored.present?
+ return page_checkout_path(account) if account.is_a?(Account) && account.requires_payment?
+
+ root_path
end
end
end
diff --git a/app/controllers/checkout_controller.rb b/app/controllers/checkout_controller.rb
index 107cbe5..064f907 100644
--- a/app/controllers/checkout_controller.rb
+++ b/app/controllers/checkout_controller.rb
@@ -6,7 +6,7 @@ class CheckoutController < ApplicationController
before_action :redirect_in_solo
def show
- url = @account.checkout_url(page_setup_url(@account, :domain), page_url(@account))
+ url = @account.checkout_url(page_url(@account), page_url(@account))
redirect_to url, status: :found, allow_other_host: true
end
diff --git a/app/controllers/concerns/payment_required.rb b/app/controllers/concerns/payment_required.rb
new file mode 100644
index 0000000..069ecf6
--- /dev/null
+++ b/app/controllers/concerns/payment_required.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module PaymentRequired
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :require_payment
+ end
+
+ private
+
+ def require_payment
+ return unless current_account&.requires_payment?
+ return if params.key?(:session_id) # Just returned from Stripe, webhook pending
+
+ redirect_to page_checkout_path(current_account)
+ end
+end
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index 6d86eb2..bfdffcf 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -2,6 +2,7 @@
class PagesController < ApplicationController
prepend_before_action :authenticate_account!
+ include PaymentRequired
before_action :set_account
layout 'dashboard'
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index ecdfe07..e0c78c3 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -2,6 +2,7 @@
class PostsController < ApplicationController
prepend_before_action :authenticate_account!
+ include PaymentRequired
before_action :set_account_from_path
before_action :set_post, only: %i[destroy edit update]
diff --git a/app/controllers/showcase_controller.rb b/app/controllers/showcase_controller.rb
index e2b60a0..dd484bf 100644
--- a/app/controllers/showcase_controller.rb
+++ b/app/controllers/showcase_controller.rb
@@ -2,6 +2,7 @@
class ShowcaseController < ApplicationController
prepend_before_action :authenticate_account!
+ include PaymentRequired
before_action :set_account_from_path
layout 'dashboard_container'
diff --git a/app/controllers/subscribers_controller.rb b/app/controllers/subscribers_controller.rb
index ddb2656..97c0578 100644
--- a/app/controllers/subscribers_controller.rb
+++ b/app/controllers/subscribers_controller.rb
@@ -4,6 +4,7 @@
class SubscribersController < ApplicationController
prepend_before_action :authenticate_account!
+ include PaymentRequired
before_action :set_account_from_path
layout 'dashboard_container'
diff --git a/app/models/account.rb b/app/models/account.rb
index b41fe3e..a455f88 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -131,6 +131,20 @@ def active_subscription?
payment_processor&.subscribed?
end
+ def requires_payment?
+ return false if Rails.configuration.solo_mode
+ return false if grandfathered?
+ return false if ever_subscribed?
+
+ !payment_processor&.subscribed?
+ end
+
+ def ever_subscribed?
+ Pay::Subscription.joins(:customer)
+ .where(pay_customers: { owner_type: 'Account', owner_id: id })
+ .exists?
+ end
+
def unverified_domain?
domains.each do |domain|
return true unless domain.verified?
@@ -212,6 +226,7 @@ def checkout_url(success_url, cancel_url) # rubocop:disable Metrics/MethodLength
allow_promotion_codes: true,
billing_address_collection: 'auto',
payment_method_collection: 'if_required',
+ subscription_data: { trial_period_days: 30 },
customer_update: {
address: 'auto',
name: 'auto'
diff --git a/app/views/layouts/dashboard.html.erb b/app/views/layouts/dashboard.html.erb
index b12b76d..120acf6 100644
--- a/app/views/layouts/dashboard.html.erb
+++ b/app/views/layouts/dashboard.html.erb
@@ -42,7 +42,7 @@ secondaryMenuItems = [
name: "Billing",
newTab: false,
to: page_billing_path(@account),
- show: @account&.active_subscription? && Rails.configuration.multiuser_mode
+ show: @account&.payment_processor&.subscribed? && Rails.configuration.multiuser_mode
},
{
name: "Queue",
diff --git a/app/views/marketing_pages/_cta.html.erb b/app/views/marketing_pages/_cta.html.erb
index 758d9d6..ee42713 100644
--- a/app/views/marketing_pages/_cta.html.erb
+++ b/app/views/marketing_pages/_cta.html.erb
@@ -7,7 +7,7 @@
Ready to set up your website?
Have your page published in minutes.
- Get started →
+ Start free trial →
diff --git a/app/views/marketing_pages/_hero.html.erb b/app/views/marketing_pages/_hero.html.erb
index f1fe3b7..284fece 100644
--- a/app/views/marketing_pages/_hero.html.erb
+++ b/app/views/marketing_pages/_hero.html.erb
@@ -27,7 +27,7 @@
- Make your website
+ Start your free trial
<%= render :partial => "shared/button_loading_arrow" %>
<%#>
diff --git a/app/views/marketing_pages/_pricing.html.erb b/app/views/marketing_pages/_pricing.html.erb
index e133808..62377b8 100644
--- a/app/views/marketing_pages/_pricing.html.erb
+++ b/app/views/marketing_pages/_pricing.html.erb
@@ -3,8 +3,8 @@
Pricing
-
Free page + newsletter
-
Affordable premium plan for hosting on a custom domain.
+
One simple plan
+
Everything you need for your personal website and newsletter.
@@ -12,71 +12,48 @@
-
+
-
- Basic
-
+ Postcard
-
Personal homepage + newsletter
+
30-day free trial
-
+
-
- <%= heroicon "check", class: "text-gray-500 h-6 w-6" %>
+ <%= heroicon "check", class: "h-6 w-6 text-accent" %>
Personalize your homepage
-
-
- <%= heroicon "check", class: "h-6 w-6 text-gray-500" %>
+ <%= heroicon "check", class: "h-6 w-6 text-accent" %>
Collect email signups on your page
-
- <%= heroicon "check", class: "h-6 w-6 text-gray-500" %>
+ <%= heroicon "check", class: "h-6 w-6 text-accent" %>
Send emails to your subscribers
-
- <%= heroicon "check", class: "h-6 w-6 text-gray-500" %>
+ <%= heroicon "check", class: "h-6 w-6 text-accent" %>
Host on postcard.page domain
-
-
-
-
-
-
-
-
-
-
Premium
-
-
-
Connect Postcard on your own domain
-
-
-
-
<%= heroicon "check", class: "h-6 w-6 text-accent" %>
@@ -90,17 +67,9 @@
Add custom code, such as analytics
-
- -
-
- <%= heroicon "check", class: "h-6 w-6 text-accent" %>
-
- Integrations + API (Coming soon)
-
-
@@ -108,4 +77,4 @@
-
\ No newline at end of file
+
diff --git a/app/views/welcome_mailer/how_i_replaced_twitter.html.erb b/app/views/welcome_mailer/how_i_replaced_twitter.html.erb
index 5342836..bec92e3 100644
--- a/app/views/welcome_mailer/how_i_replaced_twitter.html.erb
+++ b/app/views/welcome_mailer/how_i_replaced_twitter.html.erb
@@ -3,7 +3,7 @@
<% end %>
- Hi, I’m Philip - the maker of Postcard! Today I want to share with you how I use Postcard for my own personal website and newsletter at philipithomas.com.
+ Hi, I’m Philip - the maker of Postcard! Today I want to share with you how I use Postcard for my own personal website and newsletter at philipithomas.postcard.page.
@@ -17,7 +17,7 @@
- (Check out a recent example here)
+ (Check out a recent example here)
diff --git a/app/views/welcome_mailer/social_networks_over.html.erb b/app/views/welcome_mailer/social_networks_over.html.erb
index e3235c2..ddb45ae 100644
--- a/app/views/welcome_mailer/social_networks_over.html.erb
+++ b/app/views/welcome_mailer/social_networks_over.html.erb
@@ -27,7 +27,7 @@
If you are ready to break the cycle on addictive social networks, try setting up a personal newsletter with Postcard. You can still participate in social networks by sharing posts on those sites. But you can build a newsletter over time that you own.
-Originally published on philipithomas.com.
+Originally published on philipithomas.postcard.page.
<% content_for :button_label do %>Write your newsletter<% end %>
diff --git a/db/migrate/20260312034422_add_grandfathered_to_accounts.rb b/db/migrate/20260312034422_add_grandfathered_to_accounts.rb
new file mode 100644
index 0000000..9d5925c
--- /dev/null
+++ b/db/migrate/20260312034422_add_grandfathered_to_accounts.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class AddGrandfatheredToAccounts < ActiveRecord::Migration[7.1]
+ def up
+ add_column :accounts, :grandfathered, :boolean, default: false, null: false
+ Account.update_all(grandfathered: true)
+ end
+
+ def down
+ remove_column :accounts, :grandfathered
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 62398b7..a289c09 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2024_06_10_215212) do
+ActiveRecord::Schema[7.1].define(version: 2026_03_12_034422) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "plpgsql"
@@ -42,6 +42,7 @@
t.bigint "ahoy_signup_visit_id"
t.bigint "pinned_post_id"
t.datetime "locked_at"
+ t.boolean "grandfathered", default: false, null: false
t.index ["confirmation_token"], name: "index_accounts_on_confirmation_token", unique: true
t.index ["email"], name: "index_accounts_on_email", unique: true
t.index ["pinned_post_id"], name: "index_accounts_on_pinned_post_id"
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
new file mode 100644
index 0000000..42598e1
--- /dev/null
+++ b/test/fixtures/accounts.yml
@@ -0,0 +1,15 @@
+grandfathered_user:
+ name: Grandfathered User
+ email: grandfathered@example.com
+ encrypted_password: <%= Devise::Encryptor.digest(Account, 'password123') %>
+ slug: grandfathered-user
+ grandfathered: true
+ accent_color: "#2c6153"
+
+new_user:
+ name: New User
+ email: newuser@example.com
+ encrypted_password: <%= Devise::Encryptor.digest(Account, 'password123') %>
+ slug: new-user
+ grandfathered: false
+ accent_color: "#2c6153"
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
new file mode 100644
index 0000000..6c2ee91
--- /dev/null
+++ b/test/models/account_test.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class AccountTest < ActiveSupport::TestCase
+ setup do
+ @original_solo_mode = Rails.configuration.solo_mode
+ @original_multiuser_mode = Rails.configuration.multiuser_mode
+ end
+
+ teardown do
+ Rails.configuration.solo_mode = @original_solo_mode
+ Rails.configuration.multiuser_mode = @original_multiuser_mode
+ end
+
+ test "active_subscription? returns true in solo mode" do
+ Rails.configuration.solo_mode = true
+ account = accounts(:new_user)
+ assert account.active_subscription?
+ end
+
+ test "active_subscription? returns false for non-subscribed multiuser account" do
+ Rails.configuration.solo_mode = false
+ account = accounts(:new_user)
+ refute account.active_subscription?
+ end
+
+ test "requires_payment? returns false in solo mode" do
+ Rails.configuration.solo_mode = true
+ account = accounts(:new_user)
+ refute account.requires_payment?
+ end
+
+ test "requires_payment? returns false for grandfathered accounts" do
+ Rails.configuration.solo_mode = false
+ account = accounts(:grandfathered_user)
+ refute account.requires_payment?
+ end
+
+ test "requires_payment? returns true for new non-grandfathered non-subscribed accounts" do
+ Rails.configuration.solo_mode = false
+ account = accounts(:new_user)
+ assert account.requires_payment?
+ end
+
+ test "ever_subscribed? returns false for fresh accounts" do
+ account = accounts(:new_user)
+ refute account.ever_subscribed?
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..96b6e32
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+ENV['RAILS_ENV'] ||= 'test'
+require_relative '../config/environment'
+require 'rails/test_help'
+
+class ActiveSupport::TestCase
+ fixtures :all
+end
From 6b78dafd22bc28a45fd1202e0cfb6186f084681c Mon Sep 17 00:00:00 2001
From: "Philip I. Thomas"
Date: Wed, 11 Mar 2026 21:16:40 -0700
Subject: [PATCH 2/2] Update brakeman ignore for checkout controller URL change
The checkout success URL changed from page_setup_url to page_url,
which changed the brakeman fingerprint. Update the ignore entry.
Co-Authored-By: Claude Opus 4.6
---
config/brakeman.ignore | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index c8784ff..af154cd 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -60,20 +60,20 @@
{
"warning_type": "Redirect",
"warning_code": 18,
- "fingerprint": "7091306dd0b02a1c02eaadd5138fef97feb5c99ac385b19973467ed117fc84e9",
+ "fingerprint": "e0630695d24b9edc6f4723712fc38efa47e131e46fd11380309fa39f79e3e6c5",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/checkout_controller.rb",
"line": 10,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
- "code": "redirect_to(Account.friendly.find(params[:page_slug]).checkout_url(page_setup_url(Account.friendly.find(params[:page_slug]), :domain), page_url(Account.friendly.find(params[:page_slug]))), :status => :found, :allow_other_host => true)",
+ "code": "redirect_to(Account.friendly.find(params[:page_slug]).checkout_url(page_url(Account.friendly.find(params[:page_slug])), page_url(Account.friendly.find(params[:page_slug]))), :status => :found, :allow_other_host => true)",
"render_path": null,
"location": {
"type": "method",
"class": "CheckoutController",
"method": "show"
},
- "user_input": "Account.friendly.find(params[:page_slug]).checkout_url(page_setup_url(Account.friendly.find(params[:page_slug]), :domain), page_url(Account.friendly.find(params[:page_slug])))",
+ "user_input": "Account.friendly.find(params[:page_slug]).checkout_url(page_url(Account.friendly.find(params[:page_slug])), page_url(Account.friendly.find(params[:page_slug])))",
"confidence": "Weak",
"cwe_id": [
601