mirror of
https://github.com/ubicloud/ubicloud.git
synced 2023-08-22 09:38:31 +03:00
Introduce billing details
We need to get billing information from user to charge them at end of the month. We decided to use Stripe as payment provider. It has lots of features. We can use low level APIs or some predefined UI from Stripe. We can keep some of the information on our database too or keep all data at Stripe and just keep Stripe identifier. I tried to find balance between them. Then decided to keep all personal data on Stripe and we only keep Stripe resource identifiers. It helps us to avoid GDPR issues. Also it helps to keep service secure, it prevent to leak confidential data in dump or logs. Stripe has two resources: customer and payment method. We need to save credit card information of users before they create any resource. Then we will charge them using these information. We have new two models: BillingInfo and PaymentMethod. BillingInfo is corresponding for customer on Stripe side. We keep stripe_id for both objects. Each project has a BillingInfo. BillingInfo can be shared by projects. So user can associate same BillingInfo with multiple project. But currently I designed UI and models as each BillingInfo attached to single project. We can relax it based on customer feedbacks. BillingInfo has invoice details such as name, address, country etc. at Stripe. PaymentMethod has metadata about credit card at Stripe. PaymentMethod has order column, users can set a priority for payment methods. We can try to charge them in order. We collect initial BillingInfo and PaymentMethod details on Stripe's UI. We tried to decrease friction for new users. We create Checkout session with setup mode. It's Stripe's UI for saving customer and credit card details. Stripe returns success url with session id. After creation we use our UI to update customer details. Stripe's Checkout session doesn't support updating customer details. If STRIPE_SECRET_KEY isn't provided, billing is disabled.
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -27,6 +27,7 @@ gem "pry"
|
||||
gem "excon"
|
||||
gem "jwt"
|
||||
gem "pagerduty", ">= 4.0"
|
||||
gem "stripe"
|
||||
gem "countries"
|
||||
|
||||
group :development do
|
||||
|
||||
@@ -223,6 +223,7 @@ GEM
|
||||
standard-performance (1.1.2)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop-performance (~> 1.18.0)
|
||||
stripe (8.6.0)
|
||||
syntax_tree (6.1.1)
|
||||
prettier_print (>= 1.2.0)
|
||||
tilt (2.2.0)
|
||||
@@ -286,6 +287,7 @@ DEPENDENCIES
|
||||
sequel_pg (>= 1.8)
|
||||
simplecov
|
||||
standard (>= 1.24.3)
|
||||
stripe
|
||||
tilt (>= 2.0.9)
|
||||
warning
|
||||
webmock
|
||||
|
||||
@@ -23,7 +23,7 @@ class CloverWeb < Roda
|
||||
csp.default_src :none
|
||||
csp.style_src :self
|
||||
csp.img_src :self, "data: image/svg+xml"
|
||||
csp.form_action :self
|
||||
csp.form_action :self, "https://checkout.stripe.com"
|
||||
csp.script_src :self, "https://cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js", "https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"
|
||||
csp.connect_src :self
|
||||
csp.base_uri :none
|
||||
|
||||
@@ -45,6 +45,8 @@ module Config
|
||||
optional :clover_session_secret, base64, clear: true
|
||||
optional :clover_column_encryption_key, base64, clear: true
|
||||
optional :pagerduty_key, string, clear: true
|
||||
optional :stripe_public_key, string, clear: true
|
||||
optional :stripe_secret_key, string, clear: true
|
||||
|
||||
# :nocov:
|
||||
override :mail_driver, (production? ? :smtp : :logger), symbol
|
||||
|
||||
@@ -43,7 +43,7 @@ module Authorization
|
||||
def self.generate_default_acls(subject, object)
|
||||
{
|
||||
acls: [
|
||||
{subjects: [subject], actions: ["Project:view", "Project:delete", "Project:user", "Project:policy"], objects: [object]},
|
||||
{subjects: [subject], actions: ["Project:view", "Project:delete", "Project:user", "Project:policy", "Project:billing"], objects: [object]},
|
||||
{subjects: [subject], actions: ["Vm:view", "Vm:create", "Vm:delete"], objects: [object]},
|
||||
{subjects: [subject], actions: ["PrivateSubnet:view", "PrivateSubnet:create", "PrivateSubnet:delete", "PrivateSubnet:nic"], objects: [object]}
|
||||
]
|
||||
|
||||
21
migrate/20230810_add_billing_info.rb
Normal file
21
migrate/20230810_add_billing_info.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Sequel.migration do
|
||||
change do
|
||||
create_table(:billing_info) do
|
||||
column :id, :uuid, primary_key: true, default: nil
|
||||
column :stripe_id, :text, collate: '"C"', null: false, unique: true
|
||||
end
|
||||
|
||||
create_table(:payment_method) do
|
||||
column :id, :uuid, primary_key: true, default: nil
|
||||
column :stripe_id, :text, collate: '"C"', null: false, unique: true
|
||||
column :order, Integer
|
||||
foreign_key :billing_info_id, :billing_info, type: :uuid
|
||||
end
|
||||
|
||||
alter_table(:project) do
|
||||
add_foreign_key :billing_info_id, :billing_info, type: :uuid, null: true
|
||||
end
|
||||
end
|
||||
end
|
||||
24
model/billing_info.rb
Normal file
24
model/billing_info.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../model"
|
||||
require "stripe"
|
||||
|
||||
class BillingInfo < Sequel::Model
|
||||
one_to_many :payment_methods
|
||||
one_to_one :project
|
||||
|
||||
include ResourceMethods
|
||||
|
||||
def stripe_data
|
||||
if (Stripe.api_key = Config.stripe_secret_key)
|
||||
@stripe_data ||= Stripe::Customer.retrieve(stripe_id)
|
||||
end
|
||||
end
|
||||
|
||||
def after_destroy
|
||||
if (Stripe.api_key = Config.stripe_secret_key)
|
||||
Stripe::Customer.delete(stripe_id)
|
||||
end
|
||||
super
|
||||
end
|
||||
end
|
||||
23
model/payment_method.rb
Normal file
23
model/payment_method.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../model"
|
||||
require "stripe"
|
||||
|
||||
class PaymentMethod < Sequel::Model
|
||||
many_to_one :billing_info
|
||||
|
||||
include ResourceMethods
|
||||
|
||||
def stripe_data
|
||||
if (Stripe.api_key = Config.stripe_secret_key)
|
||||
@stripe_data ||= Stripe::PaymentMethod.retrieve(stripe_id)
|
||||
end
|
||||
end
|
||||
|
||||
def after_destroy
|
||||
if (Stripe.api_key = Config.stripe_secret_key)
|
||||
Stripe::PaymentMethod.detach(stripe_id)
|
||||
end
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ require_relative "../model"
|
||||
class Project < Sequel::Model
|
||||
one_to_many :access_tags
|
||||
one_to_many :access_policies
|
||||
one_to_one :billing_info, key: :id, primary_key: :billing_info_id
|
||||
|
||||
many_to_many :vms, join_table: AccessTag.table_name, left_key: :project_id, right_key: :hyper_tag_id
|
||||
many_to_many :private_subnets, join_table: AccessTag.table_name, left_key: :project_id, right_key: :hyper_tag_id
|
||||
|
||||
105
routes/web/project/billing.rb
Normal file
105
routes/web/project/billing.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "stripe"
|
||||
require "countries"
|
||||
|
||||
class CloverWeb
|
||||
hash_branch(:project_prefix, "billing") do |r|
|
||||
unless (Stripe.api_key = Config.stripe_secret_key)
|
||||
response.status = 501
|
||||
return "Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing."
|
||||
end
|
||||
|
||||
Authorization.authorize(@current_user.id, "Project:billing", @project.id)
|
||||
|
||||
r.get true do
|
||||
if (billing_info = @project.billing_info)
|
||||
@billing_info_data = Serializers::Web::BillingInfo.serialize(billing_info)
|
||||
# TODO: Use list payment methods API instead of fetching them one by one.
|
||||
@payment_methods = Serializers::Web::PaymentMethod.serialize(billing_info.payment_methods)
|
||||
end
|
||||
|
||||
view "project/billing"
|
||||
end
|
||||
|
||||
r.post true do
|
||||
if (billing_info = @project.billing_info)
|
||||
Stripe::Customer.update(billing_info.stripe_id, {
|
||||
name: r.params["name"],
|
||||
email: r.params["email"],
|
||||
address: {
|
||||
country: r.params["country"],
|
||||
state: r.params["state"],
|
||||
city: r.params["city"],
|
||||
postal_code: r.params["postal_code"],
|
||||
line1: r.params["address"],
|
||||
line2: nil
|
||||
}
|
||||
})
|
||||
|
||||
return r.redirect @project.path + "/billing"
|
||||
end
|
||||
|
||||
checkout = Stripe::Checkout::Session.create(
|
||||
payment_method_types: ["card"],
|
||||
mode: "setup",
|
||||
customer_creation: "always",
|
||||
billing_address_collection: "required",
|
||||
success_url: "#{base_url}#{@project.path}/billing/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: "#{base_url}#{@project.path}/billing"
|
||||
)
|
||||
|
||||
r.redirect checkout.url, 303
|
||||
end
|
||||
|
||||
r.get "success" do
|
||||
checkout_session = Stripe::Checkout::Session.retrieve(r.params["session_id"])
|
||||
setup_intent = Stripe::SetupIntent.retrieve(checkout_session["setup_intent"])
|
||||
|
||||
DB.transaction do
|
||||
unless (billing_info = @project.billing_info)
|
||||
billing_info = BillingInfo.create_with_id(stripe_id: setup_intent["customer"])
|
||||
@project.update(billing_info_id: billing_info.id)
|
||||
end
|
||||
|
||||
PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: setup_intent["payment_method"])
|
||||
end
|
||||
|
||||
r.redirect @project.path + "/billing"
|
||||
end
|
||||
|
||||
r.on "payment-method" do
|
||||
r.get "create" do
|
||||
unless (billing_info = @project.billing_info)
|
||||
response.status = 404
|
||||
r.halt
|
||||
end
|
||||
|
||||
checkout = Stripe::Checkout::Session.create(
|
||||
payment_method_types: ["card"],
|
||||
mode: "setup",
|
||||
customer: billing_info.stripe_id,
|
||||
success_url: "#{base_url}#{@project.path}/billing/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: "#{base_url}#{@project.path}/billing"
|
||||
)
|
||||
|
||||
r.redirect checkout.url, 303
|
||||
end
|
||||
|
||||
r.is String do |pm_ubid|
|
||||
payment_method = PaymentMethod.from_ubid(pm_ubid)
|
||||
|
||||
unless payment_method
|
||||
response.status = 404
|
||||
r.halt
|
||||
end
|
||||
|
||||
r.delete true do
|
||||
payment_method.destroy
|
||||
|
||||
return {message: "Deleting #{payment_method.ubid}"}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,6 +12,7 @@ class CloverWeb
|
||||
|
||||
r.post true do
|
||||
Authorization.authorize(@current_user.id, "Vm:create", @project.id)
|
||||
|
||||
ps_id = r.params["private-subnet-id"].empty? ? nil : UBID.parse(r.params["private-subnet-id"]).to_uuid
|
||||
Authorization.authorize(@current_user.id, "PrivateSubnet:view", ps_id)
|
||||
|
||||
|
||||
23
serializers/web/billing_info.rb
Normal file
23
serializers/web/billing_info.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "countries"
|
||||
|
||||
class Serializers::Web::BillingInfo < Serializers::Base
|
||||
def self.base(bi)
|
||||
{
|
||||
id: bi.id,
|
||||
ubid: bi.ubid,
|
||||
name: bi.stripe_data["name"],
|
||||
email: bi.stripe_data["email"],
|
||||
address: [bi.stripe_data["address"]["line1"], bi.stripe_data["address"]["line2"]].compact.join(" "),
|
||||
country: bi.stripe_data["address"]["country"],
|
||||
city: bi.stripe_data["address"]["city"],
|
||||
state: bi.stripe_data["address"]["state"],
|
||||
postal_code: bi.stripe_data["address"]["postal_code"]
|
||||
}
|
||||
end
|
||||
|
||||
structure(:default) do |bi|
|
||||
base(bi)
|
||||
end
|
||||
end
|
||||
19
serializers/web/payment_method.rb
Normal file
19
serializers/web/payment_method.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Serializers::Web::PaymentMethod < Serializers::Base
|
||||
def self.base(pm)
|
||||
{
|
||||
id: pm.id,
|
||||
ubid: pm.ubid,
|
||||
last4: pm.stripe_data["card"]["last4"],
|
||||
brand: pm.stripe_data["card"]["brand"],
|
||||
exp_month: pm.stripe_data["card"]["exp_month"],
|
||||
exp_year: pm.stripe_data["card"]["exp_year"],
|
||||
order: pm.order
|
||||
}
|
||||
end
|
||||
|
||||
structure(:default) do |pm|
|
||||
base(pm)
|
||||
end
|
||||
end
|
||||
33
spec/model/billing_info_spec.rb
Normal file
33
spec/model/billing_info_spec.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
RSpec.describe BillingInfo do
|
||||
subject(:billing_info) { described_class.create_with_id(stripe_id: "cs_1234567890") }
|
||||
|
||||
it "return Stripe Data if Stripe enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
|
||||
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"id" => "cs_1234567890"})
|
||||
expect(billing_info.stripe_data).to eq({"id" => "cs_1234567890"})
|
||||
end
|
||||
|
||||
it "not return Stripe Data if Stripe not enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return(nil)
|
||||
expect(Stripe::Customer).not_to receive(:retrieve)
|
||||
expect(billing_info.stripe_data).to be_nil
|
||||
end
|
||||
|
||||
it "delete Stripe customer if Stripe enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
|
||||
expect(Stripe::Customer).to receive(:delete).with("cs_1234567890")
|
||||
|
||||
billing_info.destroy
|
||||
end
|
||||
|
||||
it "not delete Stripe customer if Stripe not enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return(nil)
|
||||
expect(Stripe::Customer).not_to receive(:delete)
|
||||
|
||||
billing_info.destroy
|
||||
end
|
||||
end
|
||||
31
spec/model/payment_method_spec.rb
Normal file
31
spec/model/payment_method_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
RSpec.describe PaymentMethod do
|
||||
subject(:payment_method) { described_class.create_with_id(stripe_id: "pm_1234567890") }
|
||||
|
||||
it "return Stripe Data if Stripe enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
|
||||
expect(Stripe::PaymentMethod).to receive(:retrieve).with("pm_1234567890").and_return({"id" => "pm_1234567890"})
|
||||
expect(payment_method.stripe_data).to eq({"id" => "pm_1234567890"})
|
||||
end
|
||||
|
||||
it "not return Stripe Data if Stripe not enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return(nil)
|
||||
expect(Stripe::PaymentMethod).not_to receive(:retrieve)
|
||||
expect(payment_method.stripe_data).to be_nil
|
||||
end
|
||||
|
||||
it "delete Stripe payment method if Stripe enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
|
||||
expect(Stripe::PaymentMethod).to receive(:detach).with("pm_1234567890")
|
||||
payment_method.destroy
|
||||
end
|
||||
|
||||
it "not delete Stripe payment method if Stripe not enabled" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return(nil)
|
||||
expect(Stripe::PaymentMethod).not_to receive(:detach)
|
||||
payment_method.destroy
|
||||
end
|
||||
end
|
||||
145
spec/routes/web/project/billing_spec.rb
Normal file
145
spec/routes/web/project/billing_spec.rb
Normal file
@@ -0,0 +1,145 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../spec_helper"
|
||||
|
||||
RSpec.describe Clover, "billing" do
|
||||
let(:user) { create_account }
|
||||
let(:project) { user.create_project_with_default_policy("project-1") }
|
||||
let(:project_wo_permissions) { user.create_project_with_default_policy("project-2", policy_body: []) }
|
||||
let(:billing_info) do
|
||||
bi = BillingInfo.create_with_id(stripe_id: "cs_1234567890")
|
||||
project.update(billing_info_id: bi.id)
|
||||
bi
|
||||
end
|
||||
|
||||
let(:payment_method) { PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_1234567890") }
|
||||
|
||||
before do
|
||||
login(user.email)
|
||||
end
|
||||
|
||||
it "disabled when Stripe secret key not provided" do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return(nil)
|
||||
|
||||
visit project.path
|
||||
within find_by_id("desktop-menu") do
|
||||
expect { click_link "Billing" }.to raise_error Capybara::ElementNotFound
|
||||
end
|
||||
expect(page.title).to eq("Ubicloud - #{project.name}")
|
||||
|
||||
visit "#{project.path}/billing"
|
||||
expect(page.status_code).to eq(501)
|
||||
expect(page).to have_content "Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing."
|
||||
end
|
||||
|
||||
context "when Stripe enabled" do
|
||||
before do
|
||||
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
|
||||
end
|
||||
|
||||
it "raises forbidden when does not have permissions" do
|
||||
project_wo_permissions
|
||||
visit "#{project_wo_permissions.path}/billing"
|
||||
|
||||
expect(page.title).to eq("Ubicloud - Forbidden")
|
||||
expect(page.status_code).to eq(403)
|
||||
expect(page).to have_content "Forbidden"
|
||||
end
|
||||
|
||||
it "can create billing info" do
|
||||
expect(Stripe::Checkout::Session).to receive(:create).and_return(OpenStruct.new({url: "#{project.path}/billing/success?session_id=session_123"}))
|
||||
expect(Stripe::Checkout::Session).to receive(:retrieve).with("session_123").and_return({"setup_intent" => "st_123456790"})
|
||||
expect(Stripe::SetupIntent).to receive(:retrieve).with("st_123456790").and_return({"customer" => "cs_1234567890", "payment_method" => "pm_1234567890"})
|
||||
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}})
|
||||
expect(Stripe::PaymentMethod).to receive(:retrieve).with("pm_1234567890").and_return({"card" => {"brand" => "visa"}})
|
||||
|
||||
visit project.path
|
||||
|
||||
within find_by_id("desktop-menu") do
|
||||
click_link "Billing"
|
||||
end
|
||||
|
||||
expect(page.title).to eq("Ubicloud - Project Billing")
|
||||
click_button "Add new billing information"
|
||||
|
||||
billing_info = project.reload.billing_info
|
||||
expect(page.status_code).to eq(200)
|
||||
expect(billing_info.stripe_id).to eq("cs_1234567890")
|
||||
expect(page).to have_field("Billing Name", with: "ACME Inc.")
|
||||
expect(billing_info.payment_methods.first.stripe_id).to eq("pm_1234567890")
|
||||
expect(page).to have_content "Visa"
|
||||
end
|
||||
|
||||
it "can update billing info" do
|
||||
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return(
|
||||
{"name" => "Old Inc.", "address" => {"country" => "NL"}},
|
||||
{"name" => "New Inc.", "address" => {"country" => "US"}}
|
||||
).twice
|
||||
expect(Stripe::Customer).to receive(:update).with(billing_info.stripe_id, anything)
|
||||
|
||||
visit "#{project.path}/billing"
|
||||
|
||||
expect(page.title).to eq("Ubicloud - Project Billing")
|
||||
fill_in "Billing Name", with: "New Inc."
|
||||
select "United States", from: "Country"
|
||||
|
||||
click_button "Update"
|
||||
|
||||
expect(page.status_code).to eq(200)
|
||||
expect(page).to have_field("Billing Name", with: "New Inc.")
|
||||
expect(page).to have_field("Country", with: "US")
|
||||
end
|
||||
|
||||
it "can add new payment method" do
|
||||
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}}).twice
|
||||
expect(Stripe::PaymentMethod).to receive(:retrieve).with(payment_method.stripe_id).and_return({"card" => {"brand" => "visa"}}).twice
|
||||
expect(Stripe::PaymentMethod).to receive(:retrieve).with("pm_222222222").and_return({"card" => {"brand" => "mastercard"}})
|
||||
expect(Stripe::Checkout::Session).to receive(:create).and_return(OpenStruct.new({url: "#{project.path}/billing/success?session_id=session_123"}))
|
||||
expect(Stripe::Checkout::Session).to receive(:retrieve).with("session_123").and_return({"setup_intent" => "st_123456790"})
|
||||
expect(Stripe::SetupIntent).to receive(:retrieve).with("st_123456790").and_return({"payment_method" => "pm_222222222"})
|
||||
|
||||
visit "#{project.path}/billing"
|
||||
|
||||
click_link "Add Payment Method"
|
||||
|
||||
expect(page.status_code).to eq(200)
|
||||
expect(page.title).to eq("Ubicloud - Project Billing")
|
||||
expect(billing_info.payment_methods.count).to eq(2)
|
||||
expect(page).to have_content "Visa"
|
||||
expect(page).to have_content "Mastercard"
|
||||
end
|
||||
|
||||
it "raises not found when payment method not exists" do
|
||||
visit "#{project.path}/billing/payment-method/08s56d4kaj94xsmrnf5v5m3mav"
|
||||
|
||||
expect(page.title).to eq("Ubicloud - Resource not found")
|
||||
expect(page.status_code).to eq(404)
|
||||
expect(page).to have_content "Resource not found"
|
||||
end
|
||||
|
||||
it "raises not found when add payment method if project not exists" do
|
||||
visit "#{project.path}/billing/payment-method/create"
|
||||
|
||||
expect(page.title).to eq("Ubicloud - Resource not found")
|
||||
expect(page.status_code).to eq(404)
|
||||
expect(page).to have_content "Resource not found"
|
||||
end
|
||||
|
||||
it "can delete payment method" do
|
||||
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}})
|
||||
expect(Stripe::PaymentMethod).to receive(:retrieve).with(payment_method.stripe_id).and_return({"card" => {"brand" => "visa"}})
|
||||
expect(Stripe::PaymentMethod).to receive(:detach).with(payment_method.stripe_id)
|
||||
|
||||
visit "#{project.path}/billing"
|
||||
|
||||
# We send delete request manually instead of just clicking to button because delete action triggered by JavaScript.
|
||||
# UI tests run without a JavaScript enginer.
|
||||
btn = find "#payment-method-#{payment_method.ubid} .delete-btn"
|
||||
page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]}
|
||||
|
||||
expect(page.status_code).to eq(200)
|
||||
expect(page.body).to eq({message: "Deleting #{payment_method.ubid}"}.to_json)
|
||||
expect(billing_info.reload.payment_methods.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -40,6 +40,7 @@ DatabaseCleaner.url_allowlist = [
|
||||
]
|
||||
|
||||
Warning.ignore([:not_reached, :unused_var], /.*lib\/mail\/parser.*/)
|
||||
Warning.ignore([:mismatched_indentations], /.*lib\/stripe\/api_operations.*/)
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.define_derived_metadata(file_path: %r{/spec/}) do |metadata|
|
||||
|
||||
2
ubid.rb
2
ubid.rb
@@ -51,6 +51,8 @@ class UBID
|
||||
TYPE_NIC = "nc"
|
||||
TYPE_BILLING_RECORD = "br"
|
||||
TYPE_INVOICE = "1v"
|
||||
TYPE_BILLING_INFO = "b1"
|
||||
TYPE_PAYMENT_METHOD = "pm"
|
||||
|
||||
CURRENT_TIMESTAMP_TYPES = [TYPE_STRAND, TYPE_SEMAPHORE]
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900"><%= title %></h3>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= description %></p>
|
||||
<div class="mt-6">
|
||||
<a
|
||||
href="<%= button_link %>"
|
||||
class="inline-flex items-center rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600"
|
||||
>
|
||||
<%== render("components/icon", locals: { name: "hero-plus", classes: "ml-0.5 mr-1.5 h-5 w-5" }) %>
|
||||
<%= button_title %>
|
||||
</a>
|
||||
<% if defined?(button_link) && button_link %>
|
||||
<a
|
||||
href="<%= button_link %>"
|
||||
class="inline-flex items-center rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600"
|
||||
>
|
||||
<%== render("components/icon", locals: { name: "hero-plus", classes: "ml-0.5 mr-1.5 h-5 w-5" }) %>
|
||||
<%= button_title %>
|
||||
</a>
|
||||
<% else %>
|
||||
<div class="flex justify-center">
|
||||
<%== render("components/form/submit_button", locals: { text: button_title }) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 8 8" class="<%= classes %>">
|
||||
<circle cx="4" cy="4" r="3"/>
|
||||
</svg>
|
||||
<% when "hero-banknotes" %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="<%= classes %>">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
<% else %>
|
||||
<p>Not found icon</p>
|
||||
<% end %>
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,17 @@
|
||||
icon: "hero-key"
|
||||
}
|
||||
) %>
|
||||
<% if Config.stripe_secret_key %>
|
||||
<%== render(
|
||||
"layouts/sidebar/item",
|
||||
locals: {
|
||||
name: "Billing",
|
||||
url: "#{@project_data[:path]}/billing",
|
||||
is_active: request.path.start_with?("#{@project_data[:path]}/billing"),
|
||||
icon: "hero-banknotes"
|
||||
}
|
||||
) %>
|
||||
<% end %>
|
||||
<%== render(
|
||||
"layouts/sidebar/item",
|
||||
locals: {
|
||||
@@ -98,9 +109,7 @@
|
||||
) %>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<% end %>
|
||||
|
||||
<li class="-mx-6 mt-auto">
|
||||
<%== render("layouts/sidebar/project_switcher") %>
|
||||
</li>
|
||||
|
||||
198
views/project/billing.erb
Normal file
198
views/project/billing.erb
Normal file
@@ -0,0 +1,198 @@
|
||||
<% @page_title = "Project Billing" %>
|
||||
|
||||
<% if @billing_info_data %>
|
||||
<div class="space-y-1">
|
||||
<%== render(
|
||||
"components/breadcrumb",
|
||||
locals: {
|
||||
back: @project_data[:path],
|
||||
parts: [%w[Projects /project], [@project_data[:name], @project_data[:path]], %w[Billing #]]
|
||||
}
|
||||
) %>
|
||||
<%== render("components/page_header", locals: { title: "Project Billing" }) %>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<!-- Billing Info Update Card -->
|
||||
<div class="overflow-hidden rounded-lg shadow ring-1 ring-black ring-opacity-5 bg-white divide-y divide-gray-200">
|
||||
<form action="<%= "#{@project_data[:path]}/billing" %>" method="POST">
|
||||
<%== csrf_tag("#{@project_data[:path]}/billing") %>
|
||||
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="space-y-12">
|
||||
<div>
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-8">
|
||||
<div class="sm:col-span-4">
|
||||
<%== render(
|
||||
"components/form/text",
|
||||
locals: {
|
||||
name: "name",
|
||||
label: "Billing Name",
|
||||
value: @billing_info_data[:name],
|
||||
attributes: {
|
||||
required: true,
|
||||
placeholder: "Individual or Company Name"
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="sm:col-span-4">
|
||||
<%== render(
|
||||
"components/form/text",
|
||||
locals: {
|
||||
name: "email",
|
||||
label: "Billing Email",
|
||||
value: @billing_info_data[:email],
|
||||
attributes: {
|
||||
required: true,
|
||||
placeholder: "Billing Email"
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="sm:col-span-4 md:col-span-2">
|
||||
<%== render(
|
||||
"components/form/country_select",
|
||||
locals: {
|
||||
selected: @billing_info_data[:country],
|
||||
attributes: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="sm:col-span-4 md:col-span-2">
|
||||
<%== render(
|
||||
"components/form/text",
|
||||
locals: {
|
||||
name: "state",
|
||||
label: "State",
|
||||
value: @billing_info_data[:state],
|
||||
attributes: {
|
||||
placeholder: "State"
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="sm:col-span-4 md:col-span-2">
|
||||
<%== render(
|
||||
"components/form/text",
|
||||
locals: {
|
||||
name: "city",
|
||||
label: "City",
|
||||
value: @billing_info_data[:city],
|
||||
attributes: {
|
||||
placeholder: "City"
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="sm:col-span-4 md:col-span-2">
|
||||
<%== render(
|
||||
"components/form/text",
|
||||
locals: {
|
||||
name: "postal_code",
|
||||
label: "Postal Code",
|
||||
value: @billing_info_data[:postal_code],
|
||||
attributes: {
|
||||
placeholder: "Postal Code"
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<%== render(
|
||||
"components/form/textarea",
|
||||
locals: {
|
||||
name: "address",
|
||||
label: "Address",
|
||||
value: @billing_info_data[:address],
|
||||
attributes: {
|
||||
required: true
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-end gap-x-6">
|
||||
<%== render("components/form/submit_button", locals: { text: "Update" }) %>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Payment Methods Card -->
|
||||
<div>
|
||||
<div class="md:flex md:items-center md:justify-between pb-1 lg:pb-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-2xl sm:tracking-tight">
|
||||
Payment Methods
|
||||
</h3>
|
||||
</div>
|
||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||
<a
|
||||
href="<%= @project_data[:path] %>/billing/payment-method/create"
|
||||
class="inline-flex items-center rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600"
|
||||
>
|
||||
Add Payment Method
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg shadow ring-1 ring-black ring-opacity-5 bg-white divide-y divide-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% if @payment_methods.count > 0 %>
|
||||
<% @payment_methods.each do |pm| %>
|
||||
<tr id="payment-method-<%= pm[:ubid]%>">
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6" scope="row">
|
||||
<%= pm[:brand].capitalize %>
|
||||
ending in
|
||||
<%= pm[:last4] %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
Expires
|
||||
<%= pm[:exp_month] %>/<%= pm[:exp_year] %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-url="<%= @project_data[:path] + "/billing/payment-method/" + pm[:ubid] %>?project_id=<%= @project.ubid %>"
|
||||
data-csrf="<%= csrf_token(@project_data[:path] + "/billing/payment-method/" + pm[:ubid], "DELETE") %>"
|
||||
data-confirmation="<%= pm[:last4] %>"
|
||||
data-redirect="<%= request.path %>"
|
||||
class="delete-btn inline-flex items-center rounded-md bg-rose-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-rose-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<tr>
|
||||
<td colspan="2"><div class="text-center text-xl p-4">No payment methods. Add new payment method to able create resources in project.</div></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<form action="<%= "#{@project_data[:path]}/billing" %>" method="POST">
|
||||
<%== csrf_tag("#{@project_data[:path]}/billing") %>
|
||||
|
||||
<%== render(
|
||||
"components/empty_state",
|
||||
locals: {
|
||||
icon: "hero-banknotes",
|
||||
title: "No billing information",
|
||||
description: "Get started by adding new billing information.",
|
||||
button_title: "Add new billing information"
|
||||
}
|
||||
) %>
|
||||
</form>
|
||||
<% end %>
|
||||
Reference in New Issue
Block a user