kern 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -0
  3. data/Gemfile.lock +13 -2
  4. data/README.md +15 -13
  5. data/Rakefile +3 -3
  6. data/app/assets/builds/tailwind.kern.css +1749 -0
  7. data/app/controllers/kern/billing_profiles/subscriptions_controller.rb +37 -0
  8. data/app/controllers/kern/billing_profiles/subscriptions_controller.rb.tt +37 -0
  9. data/app/controllers/kern/settings/subscriptions_controller.rb +6 -0
  10. data/app/controllers/kern/signups_controller.rb +1 -1
  11. data/app/models/billing_profile/subscription.rb +16 -0
  12. data/app/models/billing_profile.rb +7 -0
  13. data/app/models/session.rb +1 -1
  14. data/app/models/signup.rb +1 -1
  15. data/app/models/user.rb +1 -1
  16. data/app/models/workspace/access.rb +3 -0
  17. data/app/models/workspace/access.rb.tt +10 -0
  18. data/app/models/workspace/billable.rb +16 -0
  19. data/app/models/workspace/feature_access.rb +28 -0
  20. data/app/models/workspace/members.rb +9 -7
  21. data/app/models/workspace/setup.rb +5 -3
  22. data/app/models/workspace.rb +2 -0
  23. data/app/views/components/_dialog.html.erb +36 -0
  24. data/app/views/kern/pages/welcome.html.erb +10 -6
  25. data/app/views/kern/settings/_cards.html.erb +11 -3
  26. data/app/views/kern/settings/subscriptions/_plan.html.erb +35 -0
  27. data/app/views/kern/settings/subscriptions/show.html.erb +11 -0
  28. data/app/views/kern/settings/users/show.html.erb +2 -2
  29. data/app/views/kern/signups/new.html.erb +2 -0
  30. data/app/views/layouts/kern/application.html.erb +4 -2
  31. data/app/views/layouts/kern/application.html.erb.tt +2 -1
  32. data/app/views/layouts/kern/auth.html.erb +2 -2
  33. data/app/webhooks/stripe/base.rb +31 -0
  34. data/app/webhooks/stripe/checkout_session_completed.rb +46 -0
  35. data/app/webhooks/stripe/customer_subscription_deleted.rb +20 -0
  36. data/app/webhooks/stripe/customer_subscription_updated.rb +33 -0
  37. data/bin/release +2 -3
  38. data/config/initializers/stripe.rb +5 -0
  39. data/config/initializers/stripe.rb.tt +3 -0
  40. data/config/routes.rb +4 -0
  41. data/db/migrate/20250101000007_create_billing_profiles.rb +14 -0
  42. data/db/migrate/20250101000008_create_billing_profile_subscriptions.rb +20 -0
  43. data/lib/generators/kern/feature/USAGE +1 -0
  44. data/lib/generators/kern/feature/feature_generator.rb +82 -19
  45. data/lib/generators/kern/install/install_generator.rb +43 -7
  46. data/lib/generators/kern/install/templates/POST_INSTALL +25 -0
  47. data/lib/generators/kern/install/templates/configurations/plans.yml +43 -0
  48. data/lib/generators/kern/install/templates/configurations/stripe.yml +11 -0
  49. data/lib/generators/kern/layouts/layouts_generator.rb +12 -0
  50. data/lib/kern/version.rb +1 -1
  51. metadata +27 -5
  52. data/app/assets/builds/tailwind.css +0 -2
  53. data/lib/generators/kern/views/USAGE +0 -22
  54. data/lib/generators/kern/views/views_generator.rb +0 -42
@@ -0,0 +1,37 @@
1
+ module Kern
2
+ class BillingProfiles::SubscriptionsController < ApplicationController
3
+ def create
4
+ session = Stripe::Checkout::Session.create({
5
+ success_url: main_app.root_url,
6
+ cancel_url: settings_subscriptions_url,
7
+ client_reference_id: Current.workspace.slug,
8
+ customer_email: Current.user.email,
9
+ mode: "subscription",
10
+
11
+ subscription_data: {
12
+ trial_period_days: 7
13
+ },
14
+
15
+ line_items: [{
16
+ quantity: 1,
17
+ price: price_id
18
+ }]
19
+ })
20
+
21
+ redirect_to session.url, status: 303, allow_other_host: true
22
+ end
23
+
24
+ def edit
25
+ session = Stripe::BillingPortal::Session.create({
26
+ customer: Current.workspace.billing_profile.external_id,
27
+ return_url: root_url
28
+ })
29
+
30
+ redirect_to session.url, status: 303, allow_other_host: true
31
+ end
32
+
33
+ private
34
+
35
+ def price_id = params[:price_id] || Config::Stripe.default_price_id
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ module Kern
2
+ class BillingProfiles::SubscriptionsController < ApplicationController
3
+ def create
4
+ session = Stripe::Checkout::Session.create({
5
+ success_url: root_url,
6
+ cancel_url: settings_subscriptions_url,
7
+ client_reference_id: Current.workspace.slug,
8
+ customer_email: Current.user.email,
9
+ mode: "subscription",
10
+
11
+ subscription_data: {
12
+ trial_period_days: 7
13
+ },
14
+
15
+ line_items: [{
16
+ quantity: 1,
17
+ price: price_id
18
+ }]
19
+ })
20
+
21
+ redirect_to session.url, status: 303, allow_other_host: true
22
+ end
23
+
24
+ def edit
25
+ session = Stripe::BillingPortal::Session.create({
26
+ customer: Current.workspace.billing_profile.external_id,
27
+ return_url: root_url
28
+ })
29
+
30
+ redirect_to session.url, status: 303, allow_other_host: true
31
+ end
32
+
33
+ private
34
+
35
+ def price_id = params[:price_id] || Config::Stripe.default_price_id
36
+ end
37
+ end
@@ -0,0 +1,6 @@
1
+ module Kern
2
+ class Settings::SubscriptionsController < ApplicationController
3
+ def show
4
+ end
5
+ end
6
+ end
@@ -16,7 +16,7 @@ module Kern
16
16
  if @signup.save
17
17
  start_new_session_for @signup.user
18
18
 
19
- redirect_to main_app.root_path
19
+ redirect_to root_url
20
20
  else
21
21
  render :new, status: :unprocessable_entity
22
22
  end
@@ -0,0 +1,16 @@
1
+ module BillingProfile
2
+ class Subscription < ApplicationRecord
3
+ include Sluggable
4
+
5
+ belongs_to :billing_profile
6
+
7
+ enum :status, %w[incomplete active trialing canceled incomplete_expired past_due unpaid free].index_by(&:itself), default: "incomplete"
8
+
9
+ store_attribute :metadata, :interval, :string
10
+ store_attribute :metadata, :product_id, :string
11
+ store_attribute :metadata, :price_id, :string
12
+ store_attribute :metadata, :quantity, :integer, default: 0
13
+
14
+ validates :quantity, numericality: {only_integer: true, greater_than_or_equal_to: 0}
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ class BillingProfile < ApplicationRecord
2
+ include Sluggable
3
+
4
+ belongs_to :workspace
5
+
6
+ has_many :subscriptions, dependent: :destroy
7
+ end
@@ -1,5 +1,5 @@
1
1
  class Session < ApplicationRecord
2
2
  belongs_to :user
3
3
 
4
- # encrypts :user_agent, :ip
4
+ encrypts :user_agent, :ip
5
5
  end
data/app/models/signup.rb CHANGED
@@ -27,7 +27,7 @@ class Signup < ApplicationForm
27
27
  def create_user = User.create!(email_address: email_address, password: password)
28
28
 
29
29
  # def send_welcome_email_to(user)
30
- # Logic to send email
30
+ # logic to send email; suggest to use https://github.com/Rails-Designer/courrier/
31
31
  # end
32
32
 
33
33
  def email_is_unique?
data/app/models/user.rb CHANGED
@@ -10,5 +10,5 @@ class User < ApplicationRecord
10
10
 
11
11
  validates :password, length: 8..128
12
12
 
13
- # encrypts :email, deterministic: true, downcase: true
13
+ encrypts :email, deterministic: true, downcase: true
14
14
  end
@@ -0,0 +1,3 @@
1
+ class Workspace::Access < RailsVault::Base
2
+ vault_attribute :member_count, :integer, default: Config::Plans.dig(:default, :features, :member_count)
3
+ end
@@ -0,0 +1,10 @@
1
+ class Workspace::Access < RailsVault::Base
2
+ # This class is using [Rails Vault](https://github.com/Rails-Designer/rails_vault/)
3
+ #
4
+ # You can define each access as follows:
5
+ #
6
+ # vault_attribute :member_count, :integer, default: Config::Plans.dig(:default, :features, :member_count)
7
+ # vault_attribute :email_notifications, :boolean, default: Config::Plans.dig(:default, :features, :email_notifications)
8
+
9
+ # For more details how this works https://railsdesigner.com/saas/saas-feature-access/
10
+ end
@@ -0,0 +1,16 @@
1
+ class Workspace
2
+ module Billable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_one :billing_profile, dependent: :destroy
7
+ end
8
+ delegate :subscriptions, to: :billing_profile
9
+
10
+ def subscribed?
11
+ return false unless billing_profile.present?
12
+
13
+ subscriptions.exists?(status: %w[active free trialing])
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ module Workspace::FeatureAccess
2
+ extend ActiveSupport::Concern
3
+
4
+ class PlanNotFoundError < StandardError; end
5
+
6
+ included do
7
+ vault :access
8
+ end
9
+
10
+ def add_access(product_id)
11
+ plan = plans[product_id.to_sym]
12
+
13
+ raise PlanNotFoundError, "Plan with product_id `#{product_id}` not found in `config/configurations/plans.yml`. Needs to be one of #{plans.keys.reject { it == :fallback }.join(", ")}." if plan.blank?
14
+
15
+ access.update plan[:features]
16
+ access
17
+ end
18
+
19
+ def reset_access!
20
+ Workspace::Access.where(resource: self).destroy_all
21
+
22
+ create_access
23
+ end
24
+
25
+ private
26
+
27
+ def plans = Config::Plans
28
+ end
@@ -1,10 +1,12 @@
1
- module Workspace::Members
2
- extend ActiveSupport::Concern
1
+ class Workspace
2
+ module Members
3
+ extend ActiveSupport::Concern
3
4
 
4
- included do
5
- has_many :members, dependent: :destroy
6
- has_many :users, through: :members
7
- end
5
+ included do
6
+ has_many :members, dependent: :destroy
7
+ has_many :users, through: :members
8
+ end
8
9
 
9
- def add_member(to:, role: nil) = Member::Setup.new(workspace: self, user: to, role: role).save
10
+ def add_member(to:, role: nil) = Member::Setup.new(workspace: self, user: to, role: role).save
11
+ end
10
12
  end
@@ -9,13 +9,15 @@ class Workspace::Setup
9
9
  ActiveRecord::Base.transaction do
10
10
  create_workspace.tap do |workspace|
11
11
  workspace.add_member to: @user, role: :owner
12
+
13
+ add_feature_access_to workspace
12
14
  end
13
15
  end
14
16
  end
15
17
 
16
18
  private
17
19
 
18
- def create_workspace
19
- Workspace.create(name: "My Workspace")
20
- end
20
+ def create_workspace = Workspace.create(name: "My Workspace")
21
+
22
+ def add_feature_access_to(workspace) = workspace.create_access
21
23
  end
@@ -1,6 +1,8 @@
1
1
  class Workspace < ApplicationRecord
2
2
  include Sluggable
3
3
 
4
+ include Billable
5
+ include FeatureAccess
4
6
  include Members
5
7
 
6
8
  validates :name, presence: true
@@ -0,0 +1,36 @@
1
+ <dialog
2
+ id="overlay"
3
+ closedby="any"
4
+ variant="centered"
5
+ class="
6
+ hidden
7
+ px-3 py-4 max-w-md w-full
8
+ opacity-100 scale-100 translate-x-0 translate-y-0
9
+ shadow-2xl
10
+ transition-discrete
11
+ transition-all duration-300
12
+ open:block
13
+ opacity-0
14
+ [&:not([open])[variant=drawer]]:translate-x-full
15
+ [&:not([open])[variant=centered]]:scale-95
16
+ starting:opacity-0
17
+
18
+ /* Modal-specific */
19
+ [&[variant=centered]]:m-auto
20
+ [&[variant=centered]]:rounded-lg
21
+ [&[variant=centered]]:starting:scale-95
22
+ [&[variant=centered]]:backdrop:bg-gray-950/10 [&[variant=centered]]:backdrop:backdrop-blur-[2px]
23
+ [&[variant=centered]]:max-sm:mb-0 [&[variant=centered]]:max-sm:rounded-b-none
24
+ [&[variant=centered]]:max-sm:starting:translate-y-full
25
+ [&[variant=centered]]:max-sm:starting:scale-100
26
+ [&[variant=centered]]:sm:my-auto
27
+
28
+ /* Drawer-specific */
29
+ [&[variant=drawer]]:m-0 [&[variant=drawer]]:ml-auto
30
+ [&[variant=drawer]]:h-screen [&[variant=drawer]]:max-h-none [&[variant=drawer]]:max-w-sm
31
+ [&[variant=drawer]]:rounded-l-lg
32
+ [&[variant=drawer]]:starting:translate-x-full
33
+ "
34
+ >
35
+ <%= tag.turbo_frame id: :modal %>
36
+ </dialog>
@@ -4,7 +4,7 @@
4
4
  </h1>
5
5
 
6
6
  <p class="mt-2 text-sm md:text-base text-gray-800">
7
- SaaS foundation for Ruby on Rails apps by <%= link_to "Rails Designer", "https://railsdesigner.com" %>. You are ready to build your next SaaS.
7
+ SaaS foundation for Ruby on Rails apps by <%= link_to "Rails Designer", "https://railsdesigner.com" %>. You are ready to build your next SaaS. 🎉
8
8
  </p>
9
9
 
10
10
  <section class="mt-6">
@@ -12,21 +12,25 @@
12
12
  Get started
13
13
  </h2>
14
14
 
15
- <ul class="list-disc list-inside marker:text-blue-500">
15
+ <ul class="list-disc list-inside marker:text-blue-500 [&_code]:font-bold">
16
16
  <li>
17
- add your own root route (<code>root to: "dashboard#show"</code>)
17
+ add your own root route (<code class="select-all">root to: "dashboard#show"</code>)
18
18
  </li>
19
19
 
20
20
  <li>
21
- install <%= link_to "Rails Icons", "https://github.com/Rails-Designer/rails_icons" %>; Kern uses the <b>Phosphor</b> library
21
+ install <%= link_to "Rails Icons", "https://github.com/Rails-Designer/rails_icons" %>; Kern uses the <b>Phosphor</b> library (but you can choose any other library)
22
22
  </li>
23
23
 
24
24
  <li>
25
- override any views by running <code>bin/rails g kern:views</code>
25
+ override any views by running <code class="select-all">bin/rails g kern:views</code>
26
26
  </li>
27
27
 
28
28
  <li>
29
- override any feature by running <code>bin/rals g kern:feature</code>
29
+ override any feature by running <code class="select-all">bin/rals g kern:feature</code>
30
+ </li>
31
+
32
+ <li>
33
+ check out <%= link_to "Rails Designer's UI Components", "https://railsdesigner.com/components/" %>
30
34
  </li>
31
35
 
32
36
  <li>
@@ -1,10 +1,18 @@
1
- <%# locals: (card_css: "col-span-12 md:col-span-4 lg:col-span-3", link_css: "block px-3 py-2 border border-gray-100 rounded-md transition ease-in-out delay-75 hover:border-gray-200 hover:shadow-lg/5", heading_css: "text-base font-bold tracking-tight text-gray-800", description_css: "mt-0.5 text-sm font-light text-gray-600") %>
2
- <ul class="grid grid-cols-12">
1
+ <%# locals: (card_css: "col-span-12 md:col-span-4 lg:col-span-3", link_css: "block px-3 py-2 border border-gray-100 rounded-md transition ease-in-out delay-75 hover:border-gray-200", heading_css: "text-base font-bold tracking-tight text-gray-800", description_css: "mt-0.5 text-sm font-light text-gray-600") %>
2
+ <ul class="grid grid-cols-12 gap-8">
3
3
  <%= tag.li class: card_css do %>
4
4
  <%= link_to settings_user_path, class: link_css do %>
5
- <%= tag.h4 "Account", class: heading_css %>
5
+ <%= tag.h4 "User settings", class: heading_css %>
6
6
 
7
7
  <%= tag.p "Update your email and password", class: description_css %>
8
8
  <% end %>
9
9
  <% end %>
10
+
11
+ <%= tag.li class: card_css do %>
12
+ <%= link_to settings_subscriptions_path, class: link_css do %>
13
+ <%= tag.h4 "Subscription", class: heading_css %>
14
+
15
+ <%= tag.p "Edit subscription, manage billing and invoices", class: description_css %>
16
+ <% end %>
17
+ <% end %>
10
18
  </ul>
@@ -0,0 +1,35 @@
1
+ <%# locals: (plan:) %>
2
+ <li class="col-span-12 flex flex-col justify-between bg-white ring ring-gray-200/60 rounded-md md:col-span-4">
3
+ <div>
4
+ <div class="px-4 py-3 bg-gray-50">
5
+ <h3 class="text-sm font-medium tracking-tight text-gray-800 md:text-base">
6
+ <%= plan[:name] %>
7
+ </h3>
8
+
9
+ <p class="mt-1">
10
+ <span class="text-lg font-light text-gray-800">$</span>
11
+ <span class="text-3xl font-semibold text-gray-800"><%= plan[:amount] %></span>
12
+ <span class="text-base font-light text-gray-800">/month</span>
13
+ </p>
14
+ </div>
15
+
16
+ <ul class="grid gap-2 grow-1 px-4 py-4 bg-white border-t border-gray-100">
17
+ <% plan[:features].each do |key, value| %>
18
+ <% next if value == false %>
19
+
20
+ <li class="flex items-center gap-x-1.5 text-sm font-medium text-gray-800">
21
+ <%= icon "check", class: "size-3 text-brand-600 shrink-0" rescue nil %>
22
+
23
+ <% if value == true %>
24
+ <%= key.to_s.humanize %>
25
+ <% else %>
26
+ <% label = key.to_s.gsub('_count', '').humanize.downcase %>
27
+ <%= pluralize(number_with_delimiter(value), label) %>
28
+ <% end %>
29
+ </li>
30
+ <% end %>
31
+ </ul>
32
+ </div>
33
+
34
+ <%= button_to "Subscribe", subscriptions_path, form_class: "flex justify-center px-4 py-3 border-t border-gray-100", params: {price_id: plan[:price_id]}, class: "btn-primary btn-block" %>
35
+ </li>
@@ -0,0 +1,11 @@
1
+ <%= component("heading", breadcrumbs: [{label: "Settings", href: settings_path}]) { "Subscription" } %>
2
+
3
+ <%= component "container" do %>
4
+ <%= tag.ul class: "grid grid-cols-12 gap-4 w-full my-20 md:gap-8" do %>
5
+ <% Config::Plans.except(:default).values.each do |plan| %>
6
+ <%= render "plan", plan: plan %>
7
+ <% end %>
8
+ <% end unless Current.workspace.subscribed? %>
9
+
10
+ <%= button_to "Go to billing", edit_subscriptions_path, method: :get, data: {turbo: false}, class: "btn-secondary" if Current.workspace.subscribed? %>
11
+ <% end %>
@@ -1,6 +1,6 @@
1
- <% content_for :title, "Account" %>
1
+ <% content_for :title, "User settings" %>
2
2
 
3
- <%= component("heading", breadcrumbs: [{label: "Settings", href: settings_path}]) { "Account" } %>
3
+ <%= component("heading", breadcrumbs: [{label: "Settings", href: settings_path}]) { "User" } %>
4
4
 
5
5
  <%= component "container" do %>
6
6
  <%= form_with model: [:settings, @user], class: "max-w-lg" do |form| %>
@@ -1,6 +1,8 @@
1
1
  <% content_for :title, "Create your account" %>
2
2
  <% content_for :page_title, "Create your account" %>
3
3
 
4
+ <%= tag.p "Configure Active Record Encryption before continuing", class: "mx-4 px-2 py-1 text-sm font-medium text-center text-red-900 bg-red-50 border border-red-100 rounded-md" if Rails.env.development? && Rails.application.credentials.active_record_encryption&.primary_key.blank? %>
5
+
4
6
  <%= form_with model: @signup, url: signup_path, class: "px-2 md:px-4" do |form| %>
5
7
  <%= form.input :email_address %>
6
8
 
@@ -8,10 +8,11 @@
8
8
  <%= csrf_meta_tags %>
9
9
  <%= csp_meta_tag %>
10
10
 
11
- <%= stylesheet_link_tag :tailwind, "data-turbo-track": "reload" %>
11
+ <%= stylesheet_link_tag "tailwind.kern", "data-turbo-track": "reload" %>
12
+ <script defer src="https://cdn.jsdelivr.net/npm/attractivejs@0.9.0"></script>
12
13
  </head>
13
14
 
14
- <body class="<%= class_names("antialiased bg-white selection:bg-pink-200/40 selection:text-pink-800/70", yield(:body_class)) %>">
15
+ <body class="<%= class_names("antialiased bg-white selection:bg-blue-200/40 selection:text-blue-800/70", yield(:body_class)) %>">
15
16
  <div class="flex">
16
17
  <%= render partial: "layouts/kern/application/navigation",
17
18
  locals: {
@@ -27,5 +28,6 @@
27
28
  </div>
28
29
 
29
30
  <%= component "flash" %>
31
+ <%= component "dialog" %>
30
32
  </body>
31
33
  </html>
@@ -8,7 +8,8 @@
8
8
  <%%= csrf_meta_tags %>
9
9
  <%%= csp_meta_tag %>
10
10
 
11
- <%%= stylesheet_link_tag :tailwind, "data-turbo-track": "reload" %>
11
+ <%%= stylesheet_link_tag "tailwind.kern", "data-turbo-track": "reload" %>
12
+ <script defer src="https://cdn.jsdelivr.net/npm/attractivejs@0.9.0"></script>
12
13
  </head>
13
14
 
14
15
  <body class="<%%= class_names("antialiased bg-white selection:bg-pink-200/40 selection:text-pink-800/70", yield(:body_class)) %>">
@@ -9,10 +9,10 @@
9
9
  <%= csrf_meta_tags %>
10
10
  <%= csp_meta_tag %>
11
11
 
12
- <%= stylesheet_link_tag :tailwind, "data-turbo-track": "reload" %>
12
+ <%= stylesheet_link_tag "tailwind.kern", "data-turbo-track": "reload" %>
13
13
  </head>
14
14
 
15
- <body class="relative antialiased overflow-clip">
15
+ <body class="relative overflow-clip antialiased bg-white selection:bg-blue-200/40 selection:text-blue-800/70">
16
16
  <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="absolute -top-[400px] -left-[350px] size-[800px] text-brand-500">
17
17
  <path fill="currentColor" d="M42.9,-43.4C60.9,-36,84.8,-27.6,92.2,-12.2C99.8,3.1,91.2,25.4,76.1,37C61.1,48.5,39.7,49.3,21.9,52.7C4,56.1,-10.4,62,-26.6,61.1C-42.9,60.3,-61.1,52.9,-61.9,41C-62.6,28.8,-46,12.1,-42.2,-7C-38.4,-25.9,-47.4,-46.5,-42.5,-56.1C-37.6,-65.8,-18.8,-64.5,-3.2,-60.7C12.4,-57,24.9,-50.8,42.9,-43.4Z" transform="translate(100 100)" />
18
18
  </svg>
@@ -0,0 +1,31 @@
1
+ module Stripe
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:stripe, :signing_secret)
5
+ signature = request.headers["Stripe-Signature"]
6
+
7
+ Stripe::Webhook.construct_event(
8
+ request.raw_post,
9
+ signature,
10
+ secret
11
+ )
12
+ rescue JSON::ParserError, Stripe::SignatureVerificationError => error
13
+ raise Fuik::InvalidSignature, error.message
14
+ end
15
+
16
+ private
17
+
18
+ def subscription_resource
19
+ @subscription_resource ||= ::Stripe::Subscription.retrieve(
20
+ payload["data"]["object"]["id"],
21
+ expand: ["items.data.price"]
22
+ )
23
+ end
24
+
25
+ def workspace = billing_profile.workspace
26
+
27
+ def billing_profile
28
+ BillingProfile.find_by!(external_id: payload["data"]["object"]["customer"])
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,46 @@
1
+ module Stripe
2
+ class CheckoutSessionCompleted < Base
3
+ def process!
4
+ billing_profile = create_billing_profile_for workspace
5
+
6
+ attach_subscription_items_to billing_profile
7
+
8
+ @webhook_event.processed!
9
+ end
10
+
11
+ private
12
+
13
+ def create_billing_profile_for(workspace)
14
+ BillingProfile.where(
15
+ workspace: workspace,
16
+ external_id: payload["data"]["object"]["customer"]
17
+ ).first_or_create
18
+ end
19
+
20
+ def attach_subscription_items_to(billing_profile)
21
+ subscription_resource["items"]["data"].each do |item|
22
+ subscription = BillingProfile::Subscription.find_or_initialize_by(
23
+ billing_profile: billing_profile,
24
+ subscription_item_id: item["id"]
25
+ )
26
+
27
+ subscription.update!(
28
+ subscription_id: subscription_resource["id"],
29
+ status: subscription_resource["status"],
30
+ cancel_at: subscription_resource["cancel_at"] ? Time.at(subscription_resource["cancel_at"]) : nil,
31
+ current_period_end_at: subscription_resource["current_period_end"] ? Time.at(subscription_resource["current_period_end"]) : nil,
32
+ quantity: item["quantity"],
33
+ product_id: item["price"]["product"],
34
+ price_id: item["price"]["id"],
35
+ interval: item["price"]["recurring"]["interval"]
36
+ )
37
+
38
+ workspace.add_access(item["price"]["product"])
39
+ end
40
+ end
41
+
42
+ def workspace
43
+ @workspace ||= Workspace.sluggable.find(payload["data"]["object"]["client_reference_id"])
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ module Stripe
2
+ class CustomerSubscriptionDeleted < Base
3
+ def process!
4
+ cancel_subscription_items_for billing_profile
5
+
6
+ @webhook_event.processed!
7
+ end
8
+
9
+ private
10
+
11
+ def cancel_subscription_items_for(billing_profile)
12
+ billing_profile.subscriptions.where(subscription_id: payload["data"]["object"]["id"]).each do |subscription|
13
+ subscription.update!(
14
+ status: "canceled",
15
+ cancel_at: Time.at(payload["data"]["object"]["canceled_at"] || payload["data"]["object"]["ended_at"] || Time.current.to_i)
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ module Stripe
2
+ class CustomerSubscriptionUpdated < Base
3
+ def process!
4
+ update_subscription_items_for billing_profile
5
+
6
+ @webhook_event.processed!
7
+ end
8
+
9
+ private
10
+
11
+ def update_subscription_items_for(billing_profile)
12
+ subscription_resource["items"]["data"].each do |item|
13
+ subscription = BillingProfile::Subscription.find_or_initialize_by(
14
+ billing_profile: billing_profile,
15
+ subscription_item_id: item["id"]
16
+ )
17
+
18
+ subscription.update!(
19
+ subscription_id: subscription_resource["id"],
20
+ status: subscription_resource["status"],
21
+ cancel_at: subscription_resource["cancel_at"] ? Time.at(subscription_resource["cancel_at"]) : nil,
22
+ current_period_end_at: subscription_resource["current_period_end"] ? Time.at(subscription_resource["current_period_end"]) : nil,
23
+ quantity: item["quantity"],
24
+ product_id: item["price"]["product"],
25
+ price_id: item["price"]["id"],
26
+ interval: item["price"]["recurring"]["interval"]
27
+ )
28
+
29
+ workspace.add_access(item["price"]["product"])
30
+ end
31
+ end
32
+ end
33
+ end
data/bin/release CHANGED
@@ -9,9 +9,8 @@ if [ -z "$VERSION" ]; then
9
9
  exit 1
10
10
  fi
11
11
 
12
- # If `VERSION` is a keyword, bump the current version
13
12
  if [[ "$VERSION" =~ ^(major|minor|patch)$ ]]; then
14
- CURRENT=$(grep -oP 'VERSION = "\K[^"]+' ./lib/kern/version.rb)
13
+ CURRENT=$(grep -o '"[^"]*"' ./lib/kern/version.rb | tr -d '"')
15
14
  IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
16
15
 
17
16
  case $VERSION in
@@ -31,5 +30,5 @@ git push
31
30
  git tag v$VERSION
32
31
  git push --tags
33
32
 
34
- rake build
33
+ bundle exec rake build
35
34
  gem push "pkg/kern-$VERSION.gem"
@@ -0,0 +1,5 @@
1
+ if defined?(Stripe)
2
+ Stripe.api_key = Config::Stripe.api_key
3
+ Stripe.api_version = Config::Stripe.api_version
4
+ Stripe.max_network_retries = Config::Stripe.max_network_retries
5
+ end
@@ -0,0 +1,3 @@
1
+ Stripe.api_key = Config::Stripe.api_key
2
+ Stripe.api_version = Config::Stripe.api_version
3
+ Stripe.max_network_retries = Config::Stripe.max_network_retries
data/config/routes.rb CHANGED
@@ -2,8 +2,12 @@ Kern::Engine.routes.draw do
2
2
  resource :settings, only: %w[show]
3
3
  namespace :settings do
4
4
  resource :user, path: "account", only: %w[show update]
5
+
6
+ resource :subscriptions, only: %w[show]
5
7
  end
6
8
 
9
+ resource :subscriptions, module: :billing_profiles, only: %w[create edit]
10
+
7
11
  resource :signup, only: %w[new create]
8
12
 
9
13
  resource :session, only: %w[new create destroy]