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:
Enes Cakir
2023-08-01 18:46:43 +03:00
committed by Enes Çakır
parent a9bd6794b0
commit 6dbf0d1d39
22 changed files with 664 additions and 11 deletions

View File

@@ -27,6 +27,7 @@ gem "pry"
gem "excon"
gem "jwt"
gem "pagerduty", ">= 4.0"
gem "stripe"
gem "countries"
group :development do

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]}
]

View 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
View 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
View 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

View File

@@ -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

View 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

View File

@@ -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)

View 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

View 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

View 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

View 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

View 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

View File

@@ -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|

View File

@@ -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]

View File

@@ -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>

View File

@@ -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 %>

View File

@@ -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
View 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 %>