Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/rules/rspec.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Database
DB_HOST=localhost
DB_USER=local
DB_PASSWORD=password
Expand All @@ -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
# 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
11 changes: 9 additions & 2 deletions .overcommit.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PreCommit:
RuboCop:
enabled: true
command: ['bundle', 'exec', 'rubocop', '-A']
command: ['bundle', 'exec', 'rubocop']
on_warn: fail

RSpec:
Expand All @@ -19,4 +19,11 @@ PreCommit:
- 'db/schema.rb'
- 'tmp/**/*'
- 'log/**/*'
- 'coverage/**/*'
- 'coverage/**/*'

CommitMsg:
ALL:
requires_files: false
quiet: false
on_warn: fail

9 changes: 9 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,6 +110,9 @@ group :test do

# For testing external APIs
gem "webmock"

# Matchers for Rails
gem "shoulda-matchers"
end

gem "haml", "~> 6.3"
Expand All @@ -126,3 +131,7 @@ gem "awesome_print"
# AI Integration
gem "omniai"
gem "omniai-anthropic"

# Stripe Subs
gem "pay", "~> 8.0"
gem "stripe", "~> 13.0"
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -605,6 +610,7 @@ DEPENDENCIES
omniai-anthropic
overcommit
pagy
pay (~> 8.0)
propshaft
puma (>= 5.0)
pundit
Expand All @@ -616,13 +622,15 @@ DEPENDENCIES
rswag-specs
rswag-ui
rubocop-rails-omakase
shoulda-matchers
simplecov
simplecov_json_formatter
solid_cable
solid_cache
solid_queue
sqlite3 (>= 2.1)
stimulus-rails
stripe (~> 13.0)
thruster
turbo-rails
tzinfo-data
Expand Down
1 change: 1 addition & 0 deletions Procfile.dev
Original file line number Diff line number Diff line change
@@ -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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,49 @@ 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
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

Expand Down Expand Up @@ -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.

7 changes: 6 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/controllers/settings/me/plans_controller.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions app/helpers/settings/me/plans_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Settings::Me::PlansHelper
end
8 changes: 7 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class User < ApplicationRecord
pay_customer default_payment_processor: :stripe

attribute :role, :string, default: "standard"

# Include Devise modules
Expand All @@ -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"
Expand All @@ -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
5 changes: 3 additions & 2 deletions app/views/devise/registrations/edit.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"

2 changes: 2 additions & 0 deletions app/views/layouts/_stripe_js.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<%= tag.meta name: "stripe-key", content: Pay::Stripe.public_key %>
<script src="https://js.stripe.com/v3/" defer></script>
8 changes: 5 additions & 3 deletions app/views/layouts/application.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions app/views/settings/me/plans/index.html.haml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/views/shared/_user_settings_sidebar.html.haml
Original file line number Diff line number Diff line change
@@ -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", '#'

Loading
Loading