From c52a5c87b6dcd170247f97fb132a3c7de9ab23de Mon Sep 17 00:00:00 2001 From: Dave Russell Date: Mon, 7 Apr 2025 05:48:57 +0100 Subject: [PATCH 1/2] [DRAFT] Stripe Subscriptions --- .env.example | 13 ++- .overcommit.yml | 11 +- Gemfile | 4 + Gemfile.lock | 5 + Procfile.dev | 1 + README.md | 35 +++++- app/controllers/application_controller.rb | 7 +- .../settings/me/plans_controller.rb | 49 +++++++++ app/helpers/application_helper.rb | 4 + app/helpers/settings/me/plans_helper.rb | 2 + app/models/user.rb | 6 + app/views/devise/registrations/edit.html.haml | 5 +- app/views/layouts/_stripe_js.html.erb | 2 + app/views/layouts/application.html.haml | 8 +- app/views/settings/me/plans/index.html.haml | 34 ++++++ .../shared/_user_settings_sidebar.html.haml | 13 +++ bin/wh | 8 ++ config/initializers/pay.rb | 23 ++++ config/initializers/stripe.rb | 2 + config/routes.rb | 6 + .../20250405180056_create_pay_tables.pay.rb | 98 +++++++++++++++++ .../20250405180057_add_pay_sti_columns.pay.rb | 25 +++++ .../20250405180339_add_names_to_user.rb | 6 + db/schema.rb | 104 +++++++++++++++++- db/seeds.rb | 10 +- 25 files changed, 466 insertions(+), 15 deletions(-) create mode 100644 app/controllers/settings/me/plans_controller.rb create mode 100644 app/helpers/settings/me/plans_helper.rb create mode 100644 app/views/layouts/_stripe_js.html.erb create mode 100644 app/views/settings/me/plans/index.html.haml create mode 100644 app/views/shared/_user_settings_sidebar.html.haml create mode 100755 bin/wh create mode 100644 config/initializers/pay.rb create mode 100644 config/initializers/stripe.rb create mode 100644 db/migrate/20250405180056_create_pay_tables.pay.rb create mode 100644 db/migrate/20250405180057_add_pay_sti_columns.pay.rb create mode 100644 db/migrate/20250405180339_add_names_to_user.rb 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..90505a1 100644 --- a/Gemfile +++ b/Gemfile @@ -126,3 +126,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..9e5495a 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) @@ -525,6 +527,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 +608,7 @@ DEPENDENCIES omniai-anthropic overcommit pagy + pay (~> 8.0) propshaft puma (>= 5.0) pundit @@ -623,6 +627,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..21e41d7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,9 +8,10 @@ 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? + private def user_not_authorized @@ -24,4 +25,8 @@ def record_not_found format.json { render json: { error: "The page you are looking for does not exist." }, status: :not_found } end end + + def stripe_enabled? + @stripe_enabled ||= ENV.fetch("STRIPE_ENABLED", "false") == "true" + end end 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..8520e41 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 @@ -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/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..c00a5b6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 } From d330248a790e5407ad749a28f9e2085cfe81b844 Mon Sep 17 00:00:00 2001 From: Dave Russell Date: Wed, 9 Apr 2025 00:01:02 +0100 Subject: [PATCH 2/2] Sub specs WIP --- .cursor/rules/rspec.mdc | 1 + Gemfile | 5 + Gemfile.lock | 3 + app/controllers/application_controller.rb | 8 +- app/models/user.rb | 2 +- config/environments/test.rb | 1 + config/routes.rb | 2 +- .../application_controller_spec.rb | 50 +++++++++ spec/factories/pay/customers.rb | 7 ++ spec/factories/pay/subscriptions.rb | 14 +++ spec/factories/users.rb | 10 +- spec/features/authentication_spec.rb | 11 +- spec/features/stripe_subscription_skip.rb | 88 +++++++++++++++ spec/models/user_spec.rb | 68 ++++++++--- spec/policies/static_page_policy_spec.rb | 2 +- spec/policies/user_policy_spec.rb | 18 +-- spec/rails_helper.rb | 18 ++- spec/requests/settings/me/plans_spec.rb | 106 ++++++++++++++++++ .../routing/settings/me/plans_routing_spec.rb | 13 +++ spec/support/stripe_webhook_payload.json | 100 +++++++++++++++++ 20 files changed, 478 insertions(+), 49 deletions(-) create mode 100644 spec/controllers/application_controller_spec.rb create mode 100644 spec/factories/pay/customers.rb create mode 100644 spec/factories/pay/subscriptions.rb create mode 100644 spec/features/stripe_subscription_skip.rb create mode 100644 spec/requests/settings/me/plans_spec.rb create mode 100644 spec/routing/settings/me/plans_routing_spec.rb create mode 100644 spec/support/stripe_webhook_payload.json 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/Gemfile b/Gemfile index 90505a1..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" diff --git a/Gemfile.lock b/Gemfile.lock index 9e5495a..abbac34 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -489,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) @@ -620,6 +622,7 @@ DEPENDENCIES rswag-specs rswag-ui rubocop-rails-omakase + shoulda-matchers simplecov simplecov_json_formatter solid_cable diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 21e41d7..06b900b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,10 @@ class ApplicationController < ActionController::Base helper_method :stripe_enabled? + def stripe_enabled? + @stripe_enabled ||= ENV.fetch("STRIPE_ENABLED", "false") == "true" + end + private def user_not_authorized @@ -25,8 +29,4 @@ def record_not_found format.json { render json: { error: "The page you are looking for does not exist." }, status: :not_found } end end - - def stripe_enabled? - @stripe_enabled ||= ENV.fetch("STRIPE_ENABLED", "false") == "true" - end end diff --git a/app/models/user.rb b/app/models/user.rb index 8520e41..69a0d7d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,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" 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/routes.rb b/config/routes.rb index c00a5b6..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" 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