diff --git a/.cursor/rules/rspec.mdc b/.cursor/rules/rspec.mdc
index 7c762b4..6b84266 100644
--- a/.cursor/rules/rspec.mdc
+++ b/.cursor/rules/rspec.mdc
@@ -13,6 +13,7 @@ alwaysApply: false
- Prefer Rspec request/feature tests over plain controller tests. Try to maintain compatibility with RSwag/OpenAPI
- Do not change any Gem versions if tests fail
- Do not test using `render_template` - check content via something like `expect(response.body).to include('content')
+- Do not test instance variables using `assigns()`
- Don't get stuck in a rabbit-hole or failing cycle. If you get stuck, please stop and ask for help
- We should be aiming for full coverage of all models, controllers, policies, helpers, jobs, and routes.
- In most cases, any changes/removals/additions to implementation code should have corresponding tests
diff --git a/.env.example b/.env.example
index a11d2c8..e75f1ba 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,4 @@
+# Database
DB_HOST=localhost
DB_USER=local
DB_PASSWORD=password
@@ -6,6 +7,16 @@ DB_QUEUE_NAME=storage/sprintzero_queue.sqlite3
DB_CABLE_NAME=storage/sprintzero_cable.sqlite3
DB_ADAPTER=sqlite3
+# Anthropic
ANTHROPIC_API_KEY=your_anthropic_api_key
-JOB_CONCURRENCY=1
\ No newline at end of file
+# Job Concurrency
+JOB_CONCURRENCY=1
+
+# Stripe
+STRIPE_ENABLED=false
+STRIPE_PUBLIC_KEY=your_stripe_public_key
+STRIPE_PRIVATE_KEY=your_stripe_private_key
+STRIPE_SIGNING_SECRET=your_stripe_signing_secret
+STRIPE_WEBHOOK_RECEIVE_TEST_EVENTS=true
+STRIPE_CUSTOMER_PORTAL_URL=your_stripe_customer_portal_url
diff --git a/.overcommit.yml b/.overcommit.yml
index 50e6a55..bd40a57 100644
--- a/.overcommit.yml
+++ b/.overcommit.yml
@@ -1,7 +1,7 @@
PreCommit:
RuboCop:
enabled: true
- command: ['bundle', 'exec', 'rubocop', '-A']
+ command: ['bundle', 'exec', 'rubocop']
on_warn: fail
RSpec:
@@ -19,4 +19,11 @@ PreCommit:
- 'db/schema.rb'
- 'tmp/**/*'
- 'log/**/*'
- - 'coverage/**/*'
\ No newline at end of file
+ - 'coverage/**/*'
+
+CommitMsg:
+ ALL:
+ requires_files: false
+ quiet: false
+ on_warn: fail
+
diff --git a/Gemfile b/Gemfile
index 33d5f1c..aeefee5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -59,6 +59,8 @@ group :development, :test do
gem "rubocop-rails-omakase", require: false
gem "dotenv"
+
+ # gem 'stripe-ruby-mock', :require => 'stripe_mock'
end
group :development do
@@ -108,6 +110,9 @@ group :test do
# For testing external APIs
gem "webmock"
+
+ # Matchers for Rails
+ gem "shoulda-matchers"
end
gem "haml", "~> 6.3"
@@ -126,3 +131,7 @@ gem "awesome_print"
# AI Integration
gem "omniai"
gem "omniai-anthropic"
+
+# Stripe Subs
+gem "pay", "~> 8.0"
+gem "stripe", "~> 13.0"
diff --git a/Gemfile.lock b/Gemfile.lock
index bab6461..abbac34 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -327,6 +327,8 @@ GEM
parser (3.3.7.4)
ast (~> 2.4.1)
racc
+ pay (8.3.0)
+ rails (>= 6.0.0)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
@@ -487,6 +489,8 @@ GEM
google-protobuf (~> 4.30)
securerandom (0.4.1)
sexp_processor (4.17.3)
+ shoulda-matchers (6.4.0)
+ activesupport (>= 5.2.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -525,6 +529,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.6)
+ stripe (13.5.0)
temple (0.10.3)
thor (1.3.2)
thruster (0.1.12)
@@ -605,6 +610,7 @@ DEPENDENCIES
omniai-anthropic
overcommit
pagy
+ pay (~> 8.0)
propshaft
puma (>= 5.0)
pundit
@@ -616,6 +622,7 @@ DEPENDENCIES
rswag-specs
rswag-ui
rubocop-rails-omakase
+ shoulda-matchers
simplecov
simplecov_json_formatter
solid_cable
@@ -623,6 +630,7 @@ DEPENDENCIES
solid_queue
sqlite3 (>= 2.1)
stimulus-rails
+ stripe (~> 13.0)
thruster
turbo-rails
tzinfo-data
diff --git a/Procfile.dev b/Procfile.dev
index bd1e30a..261f7f4 100644
--- a/Procfile.dev
+++ b/Procfile.dev
@@ -1,3 +1,4 @@
web: env RUBY_DEBUG_OPEN=true bin/rails server -p 3000
css: bin/rails dartsass:watch
jobs: bin/jobs
+webhooks: bin/wh
\ No newline at end of file
diff --git a/README.md b/README.md
index e8d5479..331765e 100644
--- a/README.md
+++ b/README.md
@@ -188,6 +188,7 @@ SprintZero uses [dotenv](https://github.com/bkeepers/dotenv) for environment con
See `.env.example` and copy the file to your `*.local` overrides as described in the instructions above.
```bash
+# Database
DB_HOST=localhost
DB_USER=local
DB_PASSWORD=password
@@ -195,9 +196,41 @@ DB_NAME=storage/sprintzero.sqlite3
DB_QUEUE_NAME=storage/sprintzero_queue.sqlite3
DB_CABLE_NAME=storage/sprintzero_cable.sqlite3
DB_ADAPTER=sqlite3
+
+# Anthropic
ANTHROPIC_API_KEY=your_anthropic_api_key
+
+# Job Concurrency
JOB_CONCURRENCY=1
+
+# Stripe
+STRIPE_ENABLED=false
+STRIPE_PUBLIC_KEY=your_stripe_public_key
+STRIPE_PRIVATE_KEY=your_stripe_private_key
+STRIPE_SIGNING_SECRET=your_stripe_signing_secret
+STRIPE_WEBHOOK_RECEIVE_TEST_EVENTS=true
+STRIPE_CUSTOMER_PORTAL_URL=your_stripe_customer_portal_url
```
+## Subscriptions
+
+Stripe is integrated to support paid subscriptions. It's as low-code integrated as possible, redirecting users to Stripe-hosted pages. Administration is also done directly through the [Stripe Dashboard]('https://dashboard.stripe.com/test/dashboard')
+
+You'll need to configure your Stripe account with Subscription Products, API keys, signing secret, and the customer billing portal. Store this data in your `.env.development.local` file.
+
+Note: Stripe hides the link to 'Developers' at the bottom of the Dashboard's sidebar.
+
+| Required Info | Where to find It |
+|--|--|
+| Public / Private API Keys | [Developers -> API Keys](https://dashboard.stripe.com/test/apikeys) |
+| Webhooks (Production only) | [Developers -> Webhooks](https://dashboard.stripe.com/test/webhooks) |
+| Signing Secret | Stripe CLI (`stripe login`) |
+
+Once you have these values set, run the Stripe webhooks listener. It's configured in the `Procfile` so is part of `dev/bin`, or you can run it manually via `bin/wh` |
+
+| Required Action | Where to do it |
+| -- | -- |
+| Create Product Plans | [Billing -> Get started with Billing -> Set up your product catalogue](https://dashboard.stripe.com/test/billing/starter-guide) |
+| Activate Customer Portal Link | [Billing -> Get started with Billing -> Set up a self-serve customer portal](https://dashboard.stripe.com/test/settings/billing/portal) |
## Git Hooks
@@ -231,5 +264,5 @@ For example:
## 💡 Contributing
-Feel free to fork, clone, and make PRs. If there's a configuration or gem you think should be included, open an issue and let's discuss.
+Feel free to fork, clone, and make PRs. If there's a configuration or gem you think should be included, open an issue and let's discuss. Or just fork it and start your own starter kit. Call it SprintOne or something. You do you. I'm (probably) not your boss.
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index a1baadc..06b900b 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -8,9 +8,14 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
- # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
+ helper_method :stripe_enabled?
+
+ def stripe_enabled?
+ @stripe_enabled ||= ENV.fetch("STRIPE_ENABLED", "false") == "true"
+ end
+
private
def user_not_authorized
diff --git a/app/controllers/settings/me/plans_controller.rb b/app/controllers/settings/me/plans_controller.rb
new file mode 100644
index 0000000..a190d4a
--- /dev/null
+++ b/app/controllers/settings/me/plans_controller.rb
@@ -0,0 +1,49 @@
+module Settings
+ module Me
+ class PlansController < ApplicationController
+ before_action :authenticate_user!
+ PLANS = HashWithIndifferentAccess.new({
+ "pro_monthly" => {
+ "price_id" => "price_1RB8uk2hdGNtxWYlO7YhVEgNp",
+ "name" => "Pro Monthly",
+ "price" => 9
+ },
+ "pro_yearly" => {
+ "price_id" => "price_1RBAQE2hdGNtxWYlMh890dxv",
+ "name" => "Pro Yearly",
+ "price" => 99
+ }
+ })
+
+ def index
+ current_user.payment_processor.sync_subscriptions(status: "all")
+
+ @plans = PLANS
+ @stripe_customer_portal_url = current_user.payment_processor.billing_portal.url
+ @subscription = current_user.payment_processor.subscription
+ end
+
+ def create
+ plan = PLANS[params[:plan_id]]
+
+ if plan.nil?
+ flash[:alert] = "Invalid plan"
+ return redirect_to settings_me_plans_path
+ end
+
+ @checkout = current_user.payment_processor.checkout(
+ mode: "subscription",
+ locale: I18n.locale,
+ line_items: [ {
+ price: plan["price_id"],
+ quantity: 1
+ } ],
+ success_url: settings_me_plans_url,
+ cancel_url: settings_me_plans_url
+ )
+
+ redirect_to @checkout.url, allow_other_host: true, status: :see_other
+ end
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index da3c251..ad9ea8a 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -5,4 +5,8 @@ def head_page_title
_set_title = content_for(:title)
_set_title.present? ? "#{_set_title} || Sprint Zero" : "Sprint Zero"
end
+
+ def user_settings_sidebar(**args)
+ render "shared/user_settings_sidebar", **args
+ end
end
diff --git a/app/helpers/settings/me/plans_helper.rb b/app/helpers/settings/me/plans_helper.rb
new file mode 100644
index 0000000..43a7267
--- /dev/null
+++ b/app/helpers/settings/me/plans_helper.rb
@@ -0,0 +1,2 @@
+module Settings::Me::PlansHelper
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 9254133..69a0d7d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,4 +1,6 @@
class User < ApplicationRecord
+ pay_customer default_payment_processor: :stripe
+
attribute :role, :string, default: "standard"
# Include Devise modules
@@ -11,7 +13,7 @@ class User < ApplicationRecord
ROLES = %w[admin standard].freeze
- validates :role, inclusion: { in: ROLES }, allow_nil: true
+ validates :role, inclusion: { in: ROLES }, allow_nil: false
def admin?
role == "admin"
@@ -20,4 +22,8 @@ def admin?
def standard?
role == "standard"
end
+
+ def active_subscription?
+ @active_subscription ||= payment_processor.subscription.present? && payment_processor.subscription.status == "active"
+ end
end
diff --git a/app/views/devise/registrations/edit.html.haml b/app/views/devise/registrations/edit.html.haml
index 90d2933..2d90183 100644
--- a/app/views/devise/registrations/edit.html.haml
+++ b/app/views/devise/registrations/edit.html.haml
@@ -2,6 +2,8 @@
.container
.columns.is-centered
.column.is-4
+ = user_settings_sidebar
+ .column.is-8
.box
%h1.title.has-text-centered Edit account
@@ -45,5 +47,4 @@
%p
Unhappy?
= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete, class: "button is-danger is-fullwidth"
-
- = render "devise/shared/links"
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/views/layouts/_stripe_js.html.erb b/app/views/layouts/_stripe_js.html.erb
new file mode 100644
index 0000000..8857b9d
--- /dev/null
+++ b/app/views/layouts/_stripe_js.html.erb
@@ -0,0 +1,2 @@
+<%= tag.meta name: "stripe-key", content: Pay::Stripe.public_key %>
+
\ No newline at end of file
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 06ba4b7..7ad94eb 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -14,6 +14,7 @@
%link{:href => "/icon.png", :rel => "apple-touch-icon"}/
= stylesheet_link_tag :app, "data-turbo-track": "reload"
= javascript_importmap_tags
+ = render "layouts/stripe_js"
%body
%nav.navbar.is-dark{:role => "navigation", "aria-label" => "main navigation"}
.container
@@ -36,10 +37,11 @@
%div.navbar-item
%div.buttons
- if user_signed_in?
- = button_to "Sign Out", destroy_user_session_path, method: :delete, class: "button is-light", data: { turbo_confirm: "Are you sure you want to sign out?" }
+ = link_to "⚙️ Settings", edit_user_registration_path, class: "button is-info"
+ = button_to "🚶➡️Sign Out", destroy_user_session_path, method: :delete, class: "button is-danger", data: { turbo_confirm: "Are you sure you want to sign out?" }
- else
- = link_to "Login", new_user_session_path, class: "button is-light"
- = link_to "Sign up", new_user_registration_path, class: "button is-primary"
+ = link_to "🔑 Login", new_user_session_path, class: "button is-light"
+ = link_to "👤 Sign up", new_user_registration_path, class: "button is-primary"
%section.section
.container
diff --git a/app/views/settings/me/plans/index.html.haml b/app/views/settings/me/plans/index.html.haml
new file mode 100644
index 0000000..e0c6fe2
--- /dev/null
+++ b/app/views/settings/me/plans/index.html.haml
@@ -0,0 +1,34 @@
+.section
+ .container
+ .columns.is-centered
+ .column.is-4
+ = user_settings_sidebar
+ .column.is-8
+ .box
+ %h1.title.has-text-centered Billing
+
+ -if @subscription.nil?
+ %p.has-text-centered.is-size-4
+ You don't have an active subscription.
+
+ %hr.mt-4
+
+ %h2.subtitle Upgrade to PRO
+ %nav.level
+ - @plans.each do |plan_id, plan|
+ .level-item
+ = form_with url: settings_me_plans_path, data: { turbo_method: :post } do |f|
+ = f.hidden_field :plan_id, value: plan_id
+
+ .field
+ .control
+ = f.submit "#{plan[:name]} - £#{plan[:price]}", class: "button is-primary"
+
+ -else
+ %p.has-text-centered.is-size-4
+ You have an active subscription.
+
+ %p
+ =link_to "Manage Subscription", @stripe_customer_portal_url, class: "button is-primary"
+ %pre
+ = @subscription.inspect
\ No newline at end of file
diff --git a/app/views/shared/_user_settings_sidebar.html.haml b/app/views/shared/_user_settings_sidebar.html.haml
new file mode 100644
index 0000000..17b28e5
--- /dev/null
+++ b/app/views/shared/_user_settings_sidebar.html.haml
@@ -0,0 +1,13 @@
+%h3.title Settings
+
+%aside.menu
+ %p.menu-label Account
+ %ul.menu-list
+ %li= link_to "Login Details", edit_user_registration_path
+
+ -if stripe_enabled?
+ %p.menu-label Billing
+ %ul.menu-list
+ %li= link_to "Plans", settings_me_plans_path
+ %li= link_to "Invoices", '#'
+
diff --git a/bin/wh b/bin/wh
new file mode 100755
index 0000000..0781315
--- /dev/null
+++ b/bin/wh
@@ -0,0 +1,8 @@
+#!/usr/bin/env ruby
+require 'dotenv'
+
+Dotenv.load('.env.development.local', '.env')
+
+exec 'sleep infinity' unless ENV.fetch('STRIPE_ENABLED', 'false') == 'true'
+
+exec 'stripe listen --forward-to localhost:3000/pay/webhooks/stripe > ./log/stripe_webhooks.log'
diff --git a/config/environments/test.rb b/config/environments/test.rb
index c2095b1..257183b 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -7,6 +7,7 @@
# Settings specified here will take precedence over those in config/application.rb.
# While tests run files are not watched, reloading is not necessary.
+ config.cache_classes = true
config.enable_reloading = false
# Eager loading loads your entire application. When running a single test locally,
diff --git a/config/initializers/pay.rb b/config/initializers/pay.rb
new file mode 100644
index 0000000..b0f896e
--- /dev/null
+++ b/config/initializers/pay.rb
@@ -0,0 +1,23 @@
+
+Pay.setup do |config|
+ # For use in the receipt/refund/renewal mailers
+ config.business_name = "SprintZero"
+ config.application_name = "SprintZero"
+ config.support_email = "SprintZero "
+
+ config.default_product_name = "default"
+ config.default_plan_name = "default"
+
+ config.automount_routes = true
+ config.routes_path = "/pay" # Only when automount_routes is true
+
+ config.enabled_processors = [ :stripe ]
+
+ # To disable all emails, set the following configuration option to false:
+ config.send_emails = true
+
+ # This example for subscription_renewing only applies to Stripe, therefore we supply the second argument of price
+ config.emails.subscription_renewing = ->(pay_subscription, price) {
+ (price&.type == "recurring") && (price.recurring&.interval == "year")
+ }
+end
diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb
new file mode 100644
index 0000000..f61793a
--- /dev/null
+++ b/config/initializers/stripe.rb
@@ -0,0 +1,2 @@
+Stripe.max_network_retries = 1
+Stripe.enable_telemetry = false
diff --git a/config/routes.rb b/config/routes.rb
index 1872e1f..6c2d574 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,5 +1,5 @@
Rails.application.routes.draw do
- devise_for :users
+ devise_for :users, class_name: "User"
authenticate :user, ->(u) { u.admin? } do
mount Avo::Engine => "/admin"
@@ -17,6 +17,12 @@
get "home", to: "home#show"
+ namespace :settings do
+ namespace :me do
+ resources :plans, only: [ :index, :create ]
+ end
+ end
+
resources :static_pages, only: [ :show, :index ]
# Defines the root path route ("/")
diff --git a/db/migrate/20250405180056_create_pay_tables.pay.rb b/db/migrate/20250405180056_create_pay_tables.pay.rb
new file mode 100644
index 0000000..1ec5d50
--- /dev/null
+++ b/db/migrate/20250405180056_create_pay_tables.pay.rb
@@ -0,0 +1,98 @@
+# This migration comes from pay (originally 1)
+class CreatePayTables < ActiveRecord::Migration[6.0]
+ def change
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
+
+ create_table :pay_customers, id: primary_key_type do |t|
+ t.belongs_to :owner, polymorphic: true, index: false, type: foreign_key_type
+ t.string :processor, null: false
+ t.string :processor_id
+ t.boolean :default
+ t.public_send Pay::Adapter.json_column_type, :data
+ t.string :stripe_account
+ t.datetime :deleted_at
+ t.timestamps
+ end
+ add_index :pay_customers, [ :owner_type, :owner_id, :deleted_at ], name: :pay_customer_owner_index, unique: true
+ add_index :pay_customers, [ :processor, :processor_id ], unique: true
+
+ create_table :pay_merchants, id: primary_key_type do |t|
+ t.belongs_to :owner, polymorphic: true, index: false, type: foreign_key_type
+ t.string :processor, null: false
+ t.string :processor_id
+ t.boolean :default
+ t.public_send Pay::Adapter.json_column_type, :data
+ t.timestamps
+ end
+ add_index :pay_merchants, [ :owner_type, :owner_id, :processor ]
+
+ create_table :pay_payment_methods, id: primary_key_type do |t|
+ t.belongs_to :customer, foreign_key: { to_table: :pay_customers }, null: false, index: false, type: foreign_key_type
+ t.string :processor_id, null: false
+ t.boolean :default
+ t.string :type
+ t.public_send Pay::Adapter.json_column_type, :data
+ t.string :stripe_account
+ t.timestamps
+ end
+ add_index :pay_payment_methods, [ :customer_id, :processor_id ], unique: true
+
+ create_table :pay_subscriptions, id: primary_key_type do |t|
+ t.belongs_to :customer, foreign_key: { to_table: :pay_customers }, null: false, index: false, type: foreign_key_type
+ t.string :name, null: false
+ t.string :processor_id, null: false
+ t.string :processor_plan, null: false
+ t.integer :quantity, default: 1, null: false
+ t.string :status, null: false
+ t.datetime :current_period_start
+ t.datetime :current_period_end
+ t.datetime :trial_ends_at
+ t.datetime :ends_at
+ t.boolean :metered
+ t.string :pause_behavior
+ t.datetime :pause_starts_at
+ t.datetime :pause_resumes_at
+ t.decimal :application_fee_percent, precision: 8, scale: 2
+ t.public_send Pay::Adapter.json_column_type, :metadata
+ t.public_send Pay::Adapter.json_column_type, :data
+ t.string :stripe_account
+ t.string :payment_method_id
+ t.timestamps
+ end
+ add_index :pay_subscriptions, [ :customer_id, :processor_id ], unique: true
+ add_index :pay_subscriptions, [ :metered ]
+ add_index :pay_subscriptions, [ :pause_starts_at ]
+
+ create_table :pay_charges, id: primary_key_type do |t|
+ t.belongs_to :customer, foreign_key: { to_table: :pay_customers }, null: false, index: false, type: foreign_key_type
+ t.belongs_to :subscription, foreign_key: { to_table: :pay_subscriptions }, null: true, type: foreign_key_type
+ t.string :processor_id, null: false
+ t.integer :amount, null: false
+ t.string :currency
+ t.integer :application_fee_amount
+ t.integer :amount_refunded
+ t.public_send Pay::Adapter.json_column_type, :metadata
+ t.public_send Pay::Adapter.json_column_type, :data
+ t.string :stripe_account
+ t.timestamps
+ end
+ add_index :pay_charges, [ :customer_id, :processor_id ], unique: true
+
+ create_table :pay_webhooks, id: primary_key_type do |t|
+ t.string :processor
+ t.string :event_type
+ t.public_send Pay::Adapter.json_column_type, :event
+ t.timestamps
+ end
+ end
+
+ private
+
+ def primary_and_foreign_key_types
+ config = Rails.configuration.generators
+ setting = config.options[config.orm][:primary_key_type]
+ primary_key_type = setting || :primary_key
+ foreign_key_type = setting || :bigint
+ [ primary_key_type, foreign_key_type ]
+ end
+end
diff --git a/db/migrate/20250405180057_add_pay_sti_columns.pay.rb b/db/migrate/20250405180057_add_pay_sti_columns.pay.rb
new file mode 100644
index 0000000..f439c60
--- /dev/null
+++ b/db/migrate/20250405180057_add_pay_sti_columns.pay.rb
@@ -0,0 +1,25 @@
+# This migration comes from pay (originally 2)
+class AddPayStiColumns < ActiveRecord::Migration[6.0]
+ def change
+ add_column :pay_customers, :type, :string
+ add_column :pay_charges, :type, :string
+ add_column :pay_subscriptions, :type, :string
+
+ rename_column :pay_payment_methods, :type, :payment_method_type
+ add_column :pay_payment_methods, :type, :string
+
+ add_column :pay_merchants, :type, :string
+
+ Pay::Customer.find_each do |pay_customer|
+ pay_customer.update(type: "Pay::#{pay_customer.processor.classify}::Customer")
+
+ pay_customer.charges.update_all(type: "Pay::#{pay_customer.processor.classify}::Charge")
+ pay_customer.subscriptions.update_all(type: "Pay::#{pay_customer.processor.classify}::Subscription")
+ pay_customer.payment_methods.update_all(type: "Pay::#{pay_customer.processor.classify}::PaymentMethod")
+ end
+
+ Pay::Merchant.find_each do |pay_merchant|
+ pay_merchant.update(type: "Pay::#{pay_merchant.processor.classify}::Merchant")
+ end
+ end
+end
diff --git a/db/migrate/20250405180339_add_names_to_user.rb b/db/migrate/20250405180339_add_names_to_user.rb
new file mode 100644
index 0000000..4956f14
--- /dev/null
+++ b/db/migrate/20250405180339_add_names_to_user.rb
@@ -0,0 +1,6 @@
+class AddNamesToUser < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :first_name, :string, default: "", null: false
+ add_column :users, :last_name, :string, default: "", null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a39e73d..35caeeb 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[8.0].define(version: 2025_04_04_052002) do
+ActiveRecord::Schema[8.0].define(version: 2025_04_05_180339) do
create_table "action_text_rich_texts", force: :cascade do |t|
t.string "name", null: false
t.text "body"
@@ -58,6 +58,102 @@
t.datetime "updated_at", null: false
end
+ create_table "pay_charges", force: :cascade do |t|
+ t.bigint "customer_id", null: false
+ t.bigint "subscription_id"
+ t.string "processor_id", null: false
+ t.integer "amount", null: false
+ t.string "currency"
+ t.integer "application_fee_amount"
+ t.integer "amount_refunded"
+ t.json "metadata"
+ t.json "data"
+ t.string "stripe_account"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "type"
+ t.index ["customer_id", "processor_id"], name: "index_pay_charges_on_customer_id_and_processor_id", unique: true
+ t.index ["subscription_id"], name: "index_pay_charges_on_subscription_id"
+ end
+
+ create_table "pay_customers", force: :cascade do |t|
+ t.string "owner_type"
+ t.bigint "owner_id"
+ t.string "processor", null: false
+ t.string "processor_id"
+ t.boolean "default"
+ t.json "data"
+ t.string "stripe_account"
+ t.datetime "deleted_at", precision: nil
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "type"
+ t.index ["owner_type", "owner_id", "deleted_at"], name: "pay_customer_owner_index", unique: true
+ t.index ["processor", "processor_id"], name: "index_pay_customers_on_processor_and_processor_id", unique: true
+ end
+
+ create_table "pay_merchants", force: :cascade do |t|
+ t.string "owner_type"
+ t.bigint "owner_id"
+ t.string "processor", null: false
+ t.string "processor_id"
+ t.boolean "default"
+ t.json "data"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "type"
+ t.index ["owner_type", "owner_id", "processor"], name: "index_pay_merchants_on_owner_type_and_owner_id_and_processor"
+ end
+
+ create_table "pay_payment_methods", force: :cascade do |t|
+ t.bigint "customer_id", null: false
+ t.string "processor_id", null: false
+ t.boolean "default"
+ t.string "payment_method_type"
+ t.json "data"
+ t.string "stripe_account"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "type"
+ t.index ["customer_id", "processor_id"], name: "index_pay_payment_methods_on_customer_id_and_processor_id", unique: true
+ end
+
+ create_table "pay_subscriptions", force: :cascade do |t|
+ t.bigint "customer_id", null: false
+ t.string "name", null: false
+ t.string "processor_id", null: false
+ t.string "processor_plan", null: false
+ t.integer "quantity", default: 1, null: false
+ t.string "status", null: false
+ t.datetime "current_period_start", precision: nil
+ t.datetime "current_period_end", precision: nil
+ t.datetime "trial_ends_at", precision: nil
+ t.datetime "ends_at", precision: nil
+ t.boolean "metered"
+ t.string "pause_behavior"
+ t.datetime "pause_starts_at", precision: nil
+ t.datetime "pause_resumes_at", precision: nil
+ t.decimal "application_fee_percent", precision: 8, scale: 2
+ t.json "metadata"
+ t.json "data"
+ t.string "stripe_account"
+ t.string "payment_method_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "type"
+ t.index ["customer_id", "processor_id"], name: "index_pay_subscriptions_on_customer_id_and_processor_id", unique: true
+ t.index ["metered"], name: "index_pay_subscriptions_on_metered"
+ t.index ["pause_starts_at"], name: "index_pay_subscriptions_on_pause_starts_at"
+ end
+
+ create_table "pay_webhooks", force: :cascade do |t|
+ t.string "processor"
+ t.string "event_type"
+ t.json "event"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "static_pages", force: :cascade do |t|
t.string "title", null: false
t.boolean "requires_sign_in", default: false, null: false
@@ -81,6 +177,8 @@
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email"
+ t.string "first_name", default: "", null: false
+ t.string "last_name", default: "", null: false
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
@@ -88,4 +186,8 @@
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "pay_charges", "pay_customers", column: "customer_id"
+ add_foreign_key "pay_charges", "pay_subscriptions", column: "subscription_id"
+ add_foreign_key "pay_payment_methods", "pay_customers", column: "customer_id"
+ add_foreign_key "pay_subscriptions", "pay_customers", column: "customer_id"
end
diff --git a/db/seeds.rb b/db/seeds.rb
index 961bcb2..1d9a7f8 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -10,6 +10,8 @@
# Create admin user
admin = User.find_or_create_by!(email: 'admin@example.com') do |user|
+ user.first_name = 'Admin'
+ user.last_name = 'User'
user.password = 'password123'
user.password_confirmation = 'password123'
user.role = 'admin'
@@ -59,14 +61,14 @@
# Create AI prompts
prompts =[
{
- title: 'Three Random Facts',
- content: 'Please generate a short article with three random facts. The generated content can use the following html tags: #{SEED_COMMON_HTML_TAGS}. Do not use any other tags or styling.',
+ title: "Three Random Facts",
+ content: "Please generate a short article with three random facts. The generated content can use the following html tags: #{SEED_COMMON_HTML_TAGS}. Do not use any other tags or styling.",
response_format: SEED_COMMON_RESPONSE,
additional_options: SEED_COMMON_OPTIONS
},
{
- title: 'Boring Legalese',
- content: 'Please generate a boring legal document. The generated content can use the following html tags: #{SEED_COMMON_HTML_TAGS}. Do not use any other tags or styling.',
+ title: "Boring Legalese",
+ content: "Please generate a boring legal document. The generated content can use the following html tags: #{SEED_COMMON_HTML_TAGS}. Do not use any other tags or styling.",
response_format: SEED_COMMON_RESPONSE,
additional_options: SEED_COMMON_OPTIONS
}
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
new file mode 100644
index 0000000..b0daf36
--- /dev/null
+++ b/spec/controllers/application_controller_spec.rb
@@ -0,0 +1,50 @@
+require 'rails_helper'
+
+RSpec.describe ApplicationController, type: :controller do
+ controller do
+ def index
+ render plain: 'OK'
+ end
+ end
+
+ describe '#stripe_enabled?' do
+ before do
+ routes.draw { get 'anonymous#index', to: 'anonymous#index' }
+ end
+
+ context 'when STRIPE_ENABLED is set to true' do
+ before do
+ allow(ENV).to receive(:fetch).with('STRIPE_ENABLED', 'false').and_return('true')
+ end
+
+ it 'returns true' do
+ expect(controller.stripe_enabled?).to be true
+ end
+
+ it 'memoizes the result' do
+ expect(ENV).to receive(:fetch).with('STRIPE_ENABLED', 'false').once.and_return('true')
+ 2.times { controller.stripe_enabled? }
+ end
+ end
+
+ context 'when STRIPE_ENABLED is set to false' do
+ before do
+ allow(ENV).to receive(:fetch).with('STRIPE_ENABLED', 'false').and_return('false')
+ end
+
+ it 'returns false' do
+ expect(controller.stripe_enabled?).to be false
+ end
+ end
+
+ context 'when STRIPE_ENABLED is not set' do
+ before do
+ allow(ENV).to receive(:fetch).with('STRIPE_ENABLED', 'false').and_return('false')
+ end
+
+ it 'returns false' do
+ expect(controller.stripe_enabled?).to be false
+ end
+ end
+ end
+end
diff --git a/spec/factories/pay/customers.rb b/spec/factories/pay/customers.rb
new file mode 100644
index 0000000..9018a0f
--- /dev/null
+++ b/spec/factories/pay/customers.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :pay_customer, class: 'Pay::Customer' do
+ association :owner, factory: :user
+ processor { 'stripe' }
+ processor_id { "cus_#{SecureRandom.hex(10)}" }
+ end
+end
diff --git a/spec/factories/pay/subscriptions.rb b/spec/factories/pay/subscriptions.rb
new file mode 100644
index 0000000..2dbc852
--- /dev/null
+++ b/spec/factories/pay/subscriptions.rb
@@ -0,0 +1,14 @@
+FactoryBot.define do
+ factory :pay_subscription, class: 'Pay::Subscription' do
+ association :customer, factory: :pay_customer
+ processor { 'stripe' }
+ processor_id { "sub_#{SecureRandom.hex(10)}" }
+ status { 'active' }
+ current_period_start { Time.current }
+ current_period_end { 1.month.from_now }
+ trial_ends_at { nil }
+ ends_at { nil }
+ pause_starts_at { nil }
+ pause_resumes_at { nil }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 05d5c9e..aa101a9 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -1,15 +1,15 @@
FactoryBot.define do
factory :user do
email { Faker::Internet.email }
- password { 'password123' }
+ password { 'P@ssword123' }
+ password_confirmation { 'P@ssword123' }
confirmed_at { Time.current }
+ role { 'standard' }
+ first_name { Faker::Name.first_name }
+ last_name { Faker::Name.last_name }
trait :admin do
role { 'admin' }
end
-
- trait :standard do
- role { 'standard' }
- end
end
end
diff --git a/spec/features/authentication_spec.rb b/spec/features/authentication_spec.rb
index 521c202..e287fb1 100644
--- a/spec/features/authentication_spec.rb
+++ b/spec/features/authentication_spec.rb
@@ -1,8 +1,8 @@
require 'rails_helper'
RSpec.describe "Authentication", type: :feature do
- let(:user) { create(:user, :standard) }
- let(:admin) { create(:user, :admin) }
+ let!(:user) { create(:user) }
+ let!(:admin) { create(:user, :admin) }
describe "Sign up" do
it "allows a user to sign up" do
@@ -91,7 +91,7 @@
describe "Email confirmation" do
it "allows a user to confirm their email" do
- user = create(:user, :standard, confirmed_at: nil)
+ user = create(:user, confirmed_at: nil)
token = user.confirmation_token
visit user_confirmation_path(confirmation_token: token)
@@ -99,7 +99,7 @@
end
it "allows a user to request confirmation email" do
- user = create(:user, :standard, confirmed_at: nil)
+ user = create(:user, confirmed_at: nil)
visit new_user_confirmation_path
fill_in "Email", with: user.email
@@ -111,7 +111,8 @@
describe "Account edit" do
it "allows a user to update their email" do
- sign_in user
+ # debugger
+ sign_in create(:user), scope: :user
visit edit_user_registration_path
fill_in "Email", with: "newemail@example.com"
diff --git a/spec/features/stripe_subscription_skip.rb b/spec/features/stripe_subscription_skip.rb
new file mode 100644
index 0000000..b649d3d
--- /dev/null
+++ b/spec/features/stripe_subscription_skip.rb
@@ -0,0 +1,88 @@
+require 'rails_helper'
+
+RSpec.describe 'Stripe Subscription', type: :feature do
+ # let!(:stripe_helper) { StripeMock.create_test_helper }
+ # before { StripeMock.start }
+ # after { StripeMock.stop }
+
+ let(:stripe_helper) { true }
+ let!(:user) { create(:user) }
+ let!(:plans) { Settings::Me::PlansController::PLANS }
+
+ before do
+ sign_in user
+ end
+
+ describe 'viewing subscription plans' do
+ context 'when user has no subscription' do
+ before do
+ allow(user.payment_processor).to receive(:sync_subscriptions)
+ allow(user.payment_processor).to receive(:subscription).and_return(nil)
+ allow(user.payment_processor).to receive(:billing_portal).and_return(
+ instance_double('Pay::BillingPortal', url: 'https://billing.stripe.com/portal')
+ )
+ end
+
+ skip 'displays available plans' do
+ visit settings_me_plans_path
+
+ expect(page).to have_content("You don't have an active subscription")
+ expect(page).to have_content("Upgrade to PRO")
+
+ plans.each do |plan_id, plan|
+ expect(page).to have_button("#{plan[:name]} - £#{plan[:price]}")
+ end
+ end
+ end
+
+ context 'when user has an active subscription' do
+ let(:subscription) { instance_double('Pay::Subscription', status: 'active') }
+
+ before do
+ allow(user.payment_processor).to receive(:sync_subscriptions)
+ allow(user.payment_processor).to receive(:subscription).and_return(subscription)
+ allow(user.payment_processor).to receive(:billing_portal).and_return(
+ instance_double('Pay::BillingPortal', url: 'https://billing.stripe.com/portal')
+ )
+ end
+
+ skip 'displays subscription management options' do
+ visit settings_me_plans_path
+
+ expect(page).to have_content("You have an active subscription")
+ expect(page).to have_link("Manage Subscription", href: 'https://billing.stripe.com/portal')
+ end
+ end
+ end
+
+ describe 'subscribing to a plan' do
+ let(:plan_id) { "pro_monthly" }
+ let(:checkout) { instance_double('Pay::Checkout', url: 'https://checkout.stripe.com/checkout') }
+
+ before do
+ allow(user.payment_processor).to receive(:sync_subscriptions)
+ allow(user.payment_processor).to receive(:subscription).and_return(nil)
+ allow(user.payment_processor).to receive(:billing_portal).and_return(
+ instance_double('Pay::BillingPortal', url: 'https://billing.stripe.com/portal')
+ )
+ allow(user.payment_processor).to receive(:checkout).and_return(checkout)
+ end
+
+ skip 'redirects to Stripe checkout when selecting a plan' do
+ visit settings_me_plans_path
+
+ expect(user.payment_processor).to receive(:checkout).with(
+ mode: "subscription",
+ locale: I18n.locale,
+ line_items: [ { price: plans[plan_id]["price_id"], quantity: 1 } ],
+ success_url: settings_me_plans_url,
+ cancel_url: settings_me_plans_url
+ ).and_return(checkout)
+
+ click_button "#{plans[plan_id][:name]} - £#{plans[plan_id][:price]}"
+
+ # In a real test, we would be redirected to Stripe, but in our test environment
+ # we're just verifying that the checkout method was called with the right parameters
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 14530e4..6afd3f7 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -2,29 +2,65 @@
RSpec.describe User, type: :model do
describe 'validations' do
- it 'validates role inclusion' do
- user = build(:user, role: 'invalid_role')
- expect(user).not_to be_valid
- expect(user.errors[:role]).to include('is not included in the list')
- end
-
- it 'allows nil role' do
- user = build(:user, role: nil)
- expect(user).to be_valid
- end
+ it { is_expected.to validate_inclusion_of(:role).in_array(%w[admin standard]) }
+ it { is_expected.to_not allow_value(nil).for(:role) }
end
describe 'role methods' do
it 'correctly identifies admin role' do
- admin = build(:user, role: 'admin')
- expect(admin.admin?).to be true
- expect(admin.standard?).to be false
+ user = build(:user, role: 'admin')
+ expect(user.admin?).to be true
end
it 'correctly identifies standard role' do
- standard = build(:user, role: 'standard')
- expect(standard.admin?).to be false
- expect(standard.standard?).to be true
+ user = build(:user, role: 'standard')
+ expect(user.standard?).to be true
+ end
+ end
+
+ describe 'payment processor integration' do
+ let(:user) { create(:user) }
+
+ it 'includes the pay_customer module' do
+ expect(User.included_modules).to include(Pay::Attributes)
+ end
+
+ it 'has a default payment processor of stripe' do
+ expect(user.payment_processor).to be_a(Pay::Customer)
+ end
+
+ describe '#active_subscription?' do
+ context 'when user has no subscription' do
+ before do
+ allow(user.payment_processor).to receive(:subscription).and_return(nil)
+ end
+
+ it 'returns false' do
+ expect(user.active_subscription?).to be false
+ end
+ end
+
+ context 'when user has an inactive subscription' do
+ before do
+ subscription = instance_double('Pay::Subscription', status: 'canceled')
+ allow(user.payment_processor).to receive(:subscription).and_return(subscription)
+ end
+
+ it 'returns false' do
+ expect(user.active_subscription?).to be false
+ end
+ end
+
+ context 'when user has an active subscription' do
+ before do
+ subscription = instance_double('Pay::Subscription', status: 'active')
+ allow(user.payment_processor).to receive(:subscription).and_return(subscription)
+ end
+
+ it 'returns true' do
+ expect(user.active_subscription?).to be true
+ end
+ end
end
end
end
diff --git a/spec/policies/static_page_policy_spec.rb b/spec/policies/static_page_policy_spec.rb
index b4e16d3..f0e274e 100644
--- a/spec/policies/static_page_policy_spec.rb
+++ b/spec/policies/static_page_policy_spec.rb
@@ -2,7 +2,7 @@
RSpec.describe StaticPagePolicy do
let(:admin) { create(:user, :admin) }
- let(:standard_user) { create(:user, :standard) }
+ let(:standard_user) { create(:user) }
context 'being an admin' do
subject { described_class.new(admin, StaticPage) }
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 9aa397d..2a11e4a 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -1,9 +1,9 @@
require 'rails_helper'
RSpec.describe UserPolicy do
- let(:admin) { create(:user, :admin) }
- let(:standard_user) { create(:user, :standard) }
- let(:other_user) { create(:user, :standard) }
+ let!(:admin) { create(:user, :admin) }
+ let!(:standard_user) { create(:user) }
+ let!(:other_user) { create(:user) }
context 'being an admin' do
subject { described_class.new(admin, User) }
@@ -40,21 +40,15 @@
end
describe 'scope' do
- before do
- @user1 = create(:user, :standard)
- @user2 = create(:user, :standard)
- @admin = create(:user, :admin)
- end
-
it 'shows all users to admin' do
- scope = Pundit.policy_scope!(@admin, User)
+ scope = Pundit.policy_scope!(admin, User)
expect(scope.count).to eq(3)
end
it 'shows only themselves to standard users' do
- scope = Pundit.policy_scope!(@user1, User)
+ scope = Pundit.policy_scope!(standard_user, User)
expect(scope.count).to eq(1)
- expect(scope.first).to eq(@user1)
+ expect(scope.first).to eq(standard_user)
end
end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index a567776..7d5f971 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -11,6 +11,7 @@
require 'factory_bot_rails'
require 'database_cleaner/active_record'
require 'rswag/specs'
+require 'warden'
require 'devise'
# Add additional requires below this line. Rails is not loaded until this point!
@@ -24,6 +25,13 @@
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.include Warden::Test::Helpers
+ config.include Devise::Test::ControllerHelpers, type: :controller
+ config.include Devise::Test::ControllerHelpers, type: :view
+ config.include Devise::Test::IntegrationHelpers, type: :feature
+ config.include Devise::Test::IntegrationHelpers, type: :request
+ config.include Devise::Test::IntegrationHelpers, type: :system
+ config.include Warden::Test::Helpers, type: :system
+ config.include Warden::Test::Helpers, type: :request
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
@@ -60,9 +68,11 @@
# Filter lines from Rails gems in backtraces.
config.filter_rails_from_backtrace!
+end
- config.include Devise::Test::ControllerHelpers, type: :controller
- config.include Devise::Test::ControllerHelpers, type: :view
- config.include Devise::Test::IntegrationHelpers, type: :feature
- config.include Devise::Test::IntegrationHelpers, type: :request
+Shoulda::Matchers.configure do |config|
+ config.integrate do |with|
+ with.test_framework :rspec
+ with.library :rails
+ end
end
diff --git a/spec/requests/settings/me/plans_spec.rb b/spec/requests/settings/me/plans_spec.rb
new file mode 100644
index 0000000..ab44aa5
--- /dev/null
+++ b/spec/requests/settings/me/plans_spec.rb
@@ -0,0 +1,106 @@
+require 'rails_helper'
+
+RSpec.describe "Settings::Me::Plans", type: :request do
+ let(:user) { create(:user) }
+ let(:plans) { Settings::Me::PlansController::PLANS }
+
+ before do
+ sign_in user
+ end
+
+ describe "GET /settings/me/plans" do
+ context "when user has no subscription" do
+ before do
+ allow(user.payment_processor).to receive(:sync_subscriptions)
+ allow(user.payment_processor).to receive(:subscription).and_return(nil)
+ allow(user.payment_processor).to receive(:billing_portal).and_return(
+ instance_double('Pay::BillingPortal', url: 'https://billing.stripe.com/portal')
+ )
+ end
+
+ it "returns a successful response" do
+ get settings_me_plans_path
+ expect(response).to be_successful
+ end
+
+ it "syncs subscriptions" do
+ expect(user.payment_processor).to receive(:sync_subscriptions).with(status: "all")
+ get settings_me_plans_path
+ end
+
+ it "displays subscription status and plans" do
+ get settings_me_plans_path
+ expect(response.body).to include("You don't have an active subscription")
+ expect(response.body).to include("Upgrade to PRO")
+
+ plans.each do |_plan_id, plan|
+ expect(response.body).to include("#{plan[:name]} - £#{plan[:price]}")
+ end
+ end
+ end
+
+ context "when user has an active subscription" do
+ let(:subscription) { instance_double('Pay::Subscription', status: 'active') }
+
+ before do
+ allow(user.payment_processor).to receive(:sync_subscriptions)
+ allow(user.payment_processor).to receive(:subscription).and_return(subscription)
+ allow(user.payment_processor).to receive(:billing_portal).and_return(
+ instance_double('Pay::BillingPortal', url: 'https://billing.stripe.com/portal')
+ )
+ end
+
+ it "returns a successful response" do
+ get settings_me_plans_path
+ expect(response).to be_successful
+ end
+
+ it "displays subscription status and management options" do
+ get settings_me_plans_path
+ expect(response.body).to include("You have an active subscription")
+ expect(response.body).to include("Manage Subscription")
+ expect(response.body).to include("https://billing.stripe.com/portal")
+ end
+ end
+ end
+
+ describe "POST /settings/me/plans" do
+ context "with a valid plan" do
+ let(:plan_id) { "pro_monthly" }
+ let(:checkout) { instance_double('Pay::Checkout', url: 'https://checkout.stripe.com/checkout') }
+
+ before do
+ allow(user.payment_processor).to receive(:checkout).and_return(checkout)
+ end
+
+ it "creates a checkout session" do
+ expect(user.payment_processor).to receive(:checkout).with(
+ mode: "subscription",
+ locale: I18n.locale,
+ line_items: [ { price: plans[plan_id]["price_id"], quantity: 1 } ],
+ success_url: settings_me_plans_url,
+ cancel_url: settings_me_plans_url
+ ).and_return(checkout)
+
+ post settings_me_plans_path, params: { plan_id: plan_id }
+ end
+
+ it "redirects to the checkout URL" do
+ post settings_me_plans_path, params: { plan_id: plan_id }
+ expect(response).to redirect_to('https://checkout.stripe.com/checkout')
+ end
+ end
+
+ context "with an invalid plan" do
+ it "sets a flash alert" do
+ post settings_me_plans_path, params: { plan_id: "invalid_plan" }
+ expect(flash[:alert]).to eq("Invalid plan")
+ end
+
+ it "redirects to the plans page" do
+ post settings_me_plans_path, params: { plan_id: "invalid_plan" }
+ expect(response).to redirect_to(settings_me_plans_path)
+ end
+ end
+ end
+end
diff --git a/spec/routing/settings/me/plans_routing_spec.rb b/spec/routing/settings/me/plans_routing_spec.rb
new file mode 100644
index 0000000..4ce70a8
--- /dev/null
+++ b/spec/routing/settings/me/plans_routing_spec.rb
@@ -0,0 +1,13 @@
+require 'rails_helper'
+
+RSpec.describe Settings::Me::PlansController, type: :routing do
+ describe 'routing' do
+ it 'routes to #index' do
+ expect(get: '/settings/me/plans').to route_to('settings/me/plans#index')
+ end
+
+ it 'routes to #create' do
+ expect(post: '/settings/me/plans').to route_to('settings/me/plans#create')
+ end
+ end
+end
diff --git a/spec/support/stripe_webhook_payload.json b/spec/support/stripe_webhook_payload.json
new file mode 100644
index 0000000..65265b8
--- /dev/null
+++ b/spec/support/stripe_webhook_payload.json
@@ -0,0 +1,100 @@
+{
+ "id": "evt_1234567890",
+ "object": "event",
+ "api_version": "2023-10-16",
+ "created": 1678901234,
+ "data": {
+ "object": {
+ "id": "sub_1234567890",
+ "object": "subscription",
+ "application_fee_percent": null,
+ "automatic_tax": {
+ "enabled": false
+ },
+ "billing_cycle_anchor": 1678901234,
+ "billing_thresholds": null,
+ "cancel_at": null,
+ "cancel_at_period_end": false,
+ "canceled_at": null,
+ "collection_method": "charge_automatically",
+ "created": 1678901234,
+ "current_period_end": 1681493234,
+ "current_period_start": 1678901234,
+ "customer": "cus_1234567890",
+ "default_payment_method": "pm_1234567890",
+ "default_source": null,
+ "default_tax_rates": [],
+ "description": null,
+ "discount": null,
+ "ended_at": null,
+ "items": {
+ "object": "list",
+ "data": [
+ {
+ "id": "si_1234567890",
+ "object": "subscription_item",
+ "billing_thresholds": null,
+ "created": 1678901234,
+ "metadata": {},
+ "price": {
+ "id": "price_1234567890",
+ "object": "price",
+ "active": true,
+ "billing_scheme": "per_unit",
+ "created": 1678901234,
+ "currency": "usd",
+ "custom_unit_amount": null,
+ "livemode": false,
+ "lookup_key": null,
+ "metadata": {},
+ "nickname": null,
+ "product": "prod_1234567890",
+ "recurring": {
+ "aggregate_usage": null,
+ "interval": "month",
+ "interval_count": 1,
+ "trial_period_days": null,
+ "usage_type": "licensed"
+ },
+ "tax_behavior": "unspecified",
+ "tiers_mode": null,
+ "transform_quantity": null,
+ "type": "recurring",
+ "unit_amount": 1000,
+ "unit_amount_decimal": "1000"
+ },
+ "quantity": 1,
+ "subscription": "sub_1234567890",
+ "tax_rates": []
+ }
+ ],
+ "has_more": false,
+ "total_count": 1,
+ "url": "/v1/subscription_items?subscription=sub_1234567890"
+ },
+ "latest_invoice": "in_1234567890",
+ "livemode": false,
+ "metadata": {},
+ "next_pending_invoice_item_invoice": null,
+ "on_behalf_of": null,
+ "pause_collection": null,
+ "payment_settings": null,
+ "phase": null,
+ "quantity": 1,
+ "schedule": null,
+ "start_date": 1678901234,
+ "status": "active",
+ "test_clock": null,
+ "transfer_data": null,
+ "trial_end": null,
+ "trial_start": null
+ }
+ },
+ "livemode": false,
+ "pending_webhooks": 0,
+ "request": {
+ "id": "req_1234567890",
+ "idempotency_key": null
+ },
+ "type": "customer.subscription.created"
+}
\ No newline at end of file