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.
- checksums.yaml +4 -4
- data/Gemfile +2 -0
- data/Gemfile.lock +13 -2
- data/README.md +15 -13
- data/Rakefile +3 -3
- data/app/assets/builds/tailwind.kern.css +1749 -0
- data/app/controllers/kern/billing_profiles/subscriptions_controller.rb +37 -0
- data/app/controllers/kern/billing_profiles/subscriptions_controller.rb.tt +37 -0
- data/app/controllers/kern/settings/subscriptions_controller.rb +6 -0
- data/app/controllers/kern/signups_controller.rb +1 -1
- data/app/models/billing_profile/subscription.rb +16 -0
- data/app/models/billing_profile.rb +7 -0
- data/app/models/session.rb +1 -1
- data/app/models/signup.rb +1 -1
- data/app/models/user.rb +1 -1
- data/app/models/workspace/access.rb +3 -0
- data/app/models/workspace/access.rb.tt +10 -0
- data/app/models/workspace/billable.rb +16 -0
- data/app/models/workspace/feature_access.rb +28 -0
- data/app/models/workspace/members.rb +9 -7
- data/app/models/workspace/setup.rb +5 -3
- data/app/models/workspace.rb +2 -0
- data/app/views/components/_dialog.html.erb +36 -0
- data/app/views/kern/pages/welcome.html.erb +10 -6
- data/app/views/kern/settings/_cards.html.erb +11 -3
- data/app/views/kern/settings/subscriptions/_plan.html.erb +35 -0
- data/app/views/kern/settings/subscriptions/show.html.erb +11 -0
- data/app/views/kern/settings/users/show.html.erb +2 -2
- data/app/views/kern/signups/new.html.erb +2 -0
- data/app/views/layouts/kern/application.html.erb +4 -2
- data/app/views/layouts/kern/application.html.erb.tt +2 -1
- data/app/views/layouts/kern/auth.html.erb +2 -2
- data/app/webhooks/stripe/base.rb +31 -0
- data/app/webhooks/stripe/checkout_session_completed.rb +46 -0
- data/app/webhooks/stripe/customer_subscription_deleted.rb +20 -0
- data/app/webhooks/stripe/customer_subscription_updated.rb +33 -0
- data/bin/release +2 -3
- data/config/initializers/stripe.rb +5 -0
- data/config/initializers/stripe.rb.tt +3 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20250101000007_create_billing_profiles.rb +14 -0
- data/db/migrate/20250101000008_create_billing_profile_subscriptions.rb +20 -0
- data/lib/generators/kern/feature/USAGE +1 -0
- data/lib/generators/kern/feature/feature_generator.rb +82 -19
- data/lib/generators/kern/install/install_generator.rb +43 -7
- data/lib/generators/kern/install/templates/POST_INSTALL +25 -0
- data/lib/generators/kern/install/templates/configurations/plans.yml +43 -0
- data/lib/generators/kern/install/templates/configurations/stripe.yml +11 -0
- data/lib/generators/kern/layouts/layouts_generator.rb +12 -0
- data/lib/kern/version.rb +1 -1
- metadata +27 -5
- data/app/assets/builds/tailwind.css +0 -2
- data/lib/generators/kern/views/USAGE +0 -22
- 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,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
|
data/app/models/session.rb
CHANGED
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
|
-
#
|
|
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
|
@@ -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
|
-
|
|
2
|
-
|
|
1
|
+
class Workspace
|
|
2
|
+
module Members
|
|
3
|
+
extend ActiveSupport::Concern
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
included do
|
|
6
|
+
has_many :members, dependent: :destroy
|
|
7
|
+
has_many :users, through: :members
|
|
8
|
+
end
|
|
8
9
|
|
|
9
|
-
|
|
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
|
-
|
|
20
|
-
|
|
20
|
+
def create_workspace = Workspace.create(name: "My Workspace")
|
|
21
|
+
|
|
22
|
+
def add_feature_access_to(workspace) = workspace.create_access
|
|
21
23
|
end
|
data/app/models/workspace.rb
CHANGED
|
@@ -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
|
|
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 "
|
|
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, "
|
|
1
|
+
<% content_for :title, "User settings" %>
|
|
2
2
|
|
|
3
|
-
<%= component("heading", breadcrumbs: [{label: "Settings", href: settings_path}]) { "
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
12
|
+
<%= stylesheet_link_tag "tailwind.kern", "data-turbo-track": "reload" %>
|
|
13
13
|
</head>
|
|
14
14
|
|
|
15
|
-
<body class="relative antialiased
|
|
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 -
|
|
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"
|
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]
|