Files
ubicloud/spec/routes/web/project/billing_spec.rb
Enes Cakir fbee450e6e Validate VAT async when it's changed
VAT validation is a costly operation, and also the European Commission
services are fragile. So, it's better to validate VAT in the background
without blocking the customer. If it fails, we can manually send an
email to the customer to inform them about the failure for now. Once we
trust the operation, we can automate the email notifications.
2025-03-19 09:46:45 +03:00

496 lines
24 KiB
Ruby

# frozen_string_literal: true
require_relative "../spec_helper"
require "pdf-reader"
require "stripe"
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", default_policy: nil) }
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 "#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.body).to eq "Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing."
end
it "tag payment method fraud after account suspension" do
expect(payment_method.reload.fraud).to be(false)
user.suspend
expect(payment_method.reload.fraud).to be(true)
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
# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::Checkout::Session).to receive(:create).and_return(double(Stripe::Checkout::Session, url: "#{project.path}/billing/success?session_id=session_123"))
expect(Stripe::PaymentIntent).to receive(:create).and_return(double(Stripe::PaymentIntent, status: "requires_capture", id: "pi_1234567890"))
# rubocop:enable RSpec/VerifiedDoubles
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"}, "metadata" => {"company_name" => "Foo Companye Name"}}).twice
expect(Stripe::PaymentMethod).to receive(:retrieve).with("pm_1234567890").and_return({"card" => {"brand" => "visa"}}).thrice
visit project.path
within "#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"
expect(page).to have_no_content "Discount"
expect(page).to have_no_content "100%"
project.this.update(discount: 100)
page.refresh
expect(page).to have_content "Discount"
expect(page).to have_content "100%"
end
it "can not create billing info with unauthorized payment" do
# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::Checkout::Session).to receive(:create).and_return(double(Stripe::Checkout::Session, url: "#{project.path}/billing/success?session_id=session_123"))
expect(Stripe::PaymentIntent).to receive(:create).and_return(double(Stripe::PaymentIntent, status: "canceled", id: "pi_1234567890"))
# rubocop:enable RSpec/VerifiedDoubles
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::PaymentMethod).to receive(:retrieve).with("pm_1234567890").and_return({"card" => {"brand" => "visa"}}).once
expect(Clog).to receive(:emit)
visit project.path
within "#desktop-menu" do
click_link "Billing"
end
expect(page.title).to eq("Ubicloud - Project Billing")
click_button "Add new billing information"
expect(page.status_code).to eq(200)
expect(page).to have_flash_error("We couldn't pre-authorize your card for verification. Please make sure it can be pre-authorized up to $5 or contact our support team at support@ubicloud.com.")
end
it "can update billing info" do
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return(
{"name" => "New Inc.", "address" => {"country" => "DE"}, "metadata" => {"tax_id" => "DE456789"}}
).at_least(:once)
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: "DE")
end
it "can update tax id" do
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return(
{"name" => "Old Inc.", "address" => {"country" => "NL"}, "metadata" => {"tax_id" => "123456"}},
{"name" => "Old Inc.", "address" => {"country" => "NL"}, "metadata" => {"tax_id" => "123456"}},
{"name" => "New Inc.", "address" => {"country" => "US"}, "metadata" => {"tax_id" => "DE456789"}}
).at_least(:once)
expect(Stripe::Customer).to receive(:update).with(billing_info.stripe_id, anything).at_least(:once)
expect(Strand).to receive(:create).with(prog: "ValidateVat", label: "start", stack: [{subject_id: billing_info.id}])
visit "#{project.path}/billing"
expect(page.title).to eq("Ubicloud - Project Billing")
select "United States", from: "Country"
fill_in "VAT ID", with: "DE 456-789"
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")
expect(page).to have_field("Tax ID", with: "DE456789")
fill_in "Tax ID", with: nil
click_button "Update"
end
it "shows error if billing info update failed" do
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return(
{"name" => "Old Inc.", "address" => {"country" => "NL"}, "metadata" => {"tax_id" => "123456"}}
).at_least(:once)
expect(Stripe::Customer).to receive(:update).and_raise(Stripe::InvalidRequestError.new("Invalid email address: test@test.com", "email"))
visit "#{project.path}/billing"
expect(page.title).to eq("Ubicloud - Project Billing")
fill_in "Billing Email", with: " test@test.com"
click_button "Update"
expect(page.status_code).to eq(200)
expect(page).to have_flash_error("Invalid email address: test@test.com")
end
it "can add new payment method" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}, "metadata" => {"company_name" => "Foo Companye Name"}}).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"}}).twice
# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::Checkout::Session).to receive(:create).and_return(double(Stripe::Checkout::Session, url: "#{project.path}/billing/success?session_id=session_123"))
expect(Stripe::PaymentIntent).to receive(:create).and_return(double(Stripe::PaymentIntent, status: "requires_capture", id: "pi_1234567890"))
# rubocop:enable RSpec/VerifiedDoubles
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 "can't add fraud payment method" do
fraud_payment_method = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pmi_1234567890", fraud: true, card_fingerprint: "cfg1234")
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}, "metadata" => {"company_name" => "Foo Companye Name"}}).twice
expect(Stripe::PaymentMethod).to receive(:retrieve).with(fraud_payment_method.stripe_id).and_return({"card" => {"brand" => "visa"}}).twice
expect(Stripe::PaymentMethod).to receive(:retrieve).with("pm_222222222").and_return({"card" => {"brand" => "mastercard", "fingerprint" => "cfg1234"}})
# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::Checkout::Session).to receive(:create).and_return(double(Stripe::Checkout::Session, url: "#{project.path}/billing/success?session_id=session_123"))
# rubocop:enable RSpec/VerifiedDoubles
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(1)
expect(page).to have_content "Visa"
expect(page).to have_flash_error("Payment method you added is labeled as fraud. Please contact support.")
end
it "raises not found when payment method not exists" do
visit "#{project.path}/billing/payment-method/08s56d4kaj94xsmrnf5v5m3mav"
expect(page.title).to eq("Ubicloud - ResourceNotFound")
expect(page.status_code).to eq(404)
expect(page).to have_content "ResourceNotFound"
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 - ResourceNotFound")
expect(page.status_code).to eq(404)
expect(page).to have_content "ResourceNotFound"
end
it "can't delete last payment method" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}, "metadata" => {"company_name" => "Foo Companye Name"}})
expect(Stripe::PaymentMethod).to receive(:retrieve).with(payment_method.stripe_id).and_return({"card" => {"brand" => "visa"}})
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 engine.
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(400)
expect(page.body).to eq({error: {message: "You can't delete the last payment method of a project."}}.to_json)
expect(billing_info.reload.payment_methods.count).to eq(1)
end
it "can delete payment method" do
payment_method_2 = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_2222222222")
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}, "metadata" => {"company_name" => "Foo Companye Name"}})
expect(Stripe::PaymentMethod).to receive(:retrieve).with(payment_method.stripe_id).and_return({"card" => {"brand" => "visa"}})
expect(Stripe::PaymentMethod).to receive(:retrieve).with(payment_method_2.stripe_id).and_return({"card" => {"brand" => "mastercard"}})
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(204)
expect(page.body).to be_empty
expect(billing_info.reload.payment_methods.count).to eq(1)
end
describe "invoices" do
def billing_record(begin_time, end_time)
vm = create_vm
BillingRecord.create_with_id(
project_id: billing_info.project.id,
resource_id: vm.id,
resource_name: vm.name,
span: Sequel::Postgres::PGRange.new(begin_time, end_time),
billing_rate_id: BillingRate.from_resource_properties("VmVCpu", vm.family, vm.location)["id"],
amount: vm.vcpus
)
end
it "list invoices of project" do
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return({"name" => "John Doe", "address" => {"country" => "NL"}, "metadata" => {}}).at_least(:once)
bi = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
InvoiceGenerator.new(bi.span.begin, bi.span.end, save_result: true, eur_rate: 1.1).run
invoice = Invoice.first
visit "#{project.path}/billing"
expect(page.status_code).to eq(200)
expect(page.title).to eq("Ubicloud - Project Billing")
expect(page).to have_content invoice.name
invoice.content["cost"] = 123.45
invoice.content["subtotal"] = 543.21
invoice.this.update(content: invoice.content)
page.refresh
expect(page).to have_content("$123.45 ($543.21)")
click_link invoice.name
end
it "show current usage details" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "NL"}, "metadata" => {"company_name" => "Foo Companye Name"}}).at_least(:once)
bi = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
100.times do
billing_record(Time.parse("2023-06-01"), Time.parse("2023-06-01") + 10)
end
invoice = InvoiceGenerator.new(bi.span.begin, bi.span.end, save_result: true, eur_rate: 1.1).run.first
invoice.update(status: "current")
expect(InvoiceGenerator).to receive(:new).and_return(instance_double(InvoiceGenerator, run: [invoice])).at_least(:once)
visit "#{project.path}/billing/invoice/current"
expect(page.status_code).to eq(200)
expect(page.title).to eq("Ubicloud - Current Usage Summary")
expect(page).to have_content "Aggregated"
expect(page.has_css?("#invoice-discount")).to be false
expect(page.has_css?("#invoice-credit")).to be false
content = invoice.content
content["discount"] = 1
content["credit"] = 2
content["free_inference_tokens_credit"] = 3
invoice.this.update(content:)
page.refresh
expect(find_by_id("invoice-discount").text).to eq "-$1.00"
expect(find_by_id("invoice-credit").text).to eq "-$2.00"
expect(find_by_id("invoice-free-inference-tokens").text).to eq "-$3.00"
end
it "show current invoice when no usage" do
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return({"name" => "John Doe", "address" => {}, "metadata" => {}}).at_least(:once)
visit "#{project.path}/billing"
expect(page.status_code).to eq(200)
expect(page.title).to eq("Ubicloud - Project Billing")
expect(page).to have_content "current"
expect(page).to have_content "not finalized"
click_link href: "#{project.path}/billing/invoice/current"
expect(page).to have_content "Current Usage Summary"
expect(page).to have_content "No resources"
end
it "list current invoice with last month usage" do
expect(Stripe::Customer).to receive(:retrieve).with(billing_info.stripe_id).and_return({"name" => "John Doe", "address" => {"country" => "NL"}, "metadata" => {}}).at_least(:once)
br_previous = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
br_current = billing_record(Time.parse("2023-07-01"), Time.parse("2023-07-15"))
invoice_previous = InvoiceGenerator.new(br_previous.span.begin, br_previous.span.end, save_result: true, eur_rate: 1.1).run.first
invoice_current = InvoiceGenerator.new(br_current.span.begin, br_current.span.end, project_ids: [project.id]).run.first
visit "#{project.path}/billing"
expect(page.status_code).to eq(200)
expect(page.title).to eq("Ubicloud - Project Billing")
expect(page).to have_content "current"
expect(page).to have_content "not finalized"
expect(page).to have_content "$%0.02f" % invoice_current.content["cost"]
expect(page).to have_content "$%0.02f" % invoice_previous.content["cost"]
click_link href: "#{project.path}/billing/invoice/current"
expect(page).to have_content "Current Usage Summary"
expect(page).to have_content "$%0.02f" % invoice_current.content["cost"]
expect(page).to have_content "less than $0.001"
end
it "show finalized invoice as PDF from US issuer without VAT" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "US"}, "metadata" => {"company_name" => "Foo Companye Name", "tax_id" => "123123123"}}).at_least(:once)
bi = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
invoice = InvoiceGenerator.new(bi.span.begin, bi.span.end, save_result: true, eur_rate: 1.1).run.first
visit "#{project.path}/billing"
click_link invoice.name
expect(page.status_code).to eq(200)
text = PDF::Reader.new(StringIO.new(page.body)).pages.map(&:text).join(" ")
expect(text).to include("Ubicloud Inc.")
expect(text).to include("ACME Inc. - Foo Companye Name")
expect(text).to include("test-vm")
expect(text).not_to include("VAT")
end
it "show finalized invoice as PDF from EU issuer with 21% VAT" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "DE"}, "metadata" => {"company_name" => "Foo Companye Name"}}).at_least(:once)
bi = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
invoice = InvoiceGenerator.new(bi.span.begin, bi.span.end, save_result: true, eur_rate: 1.1).run.first
visit "#{project.path}/billing"
expect(page).to have_content "EU registered business can enter their VAT ID to remove VAT from future invoices."
click_link invoice.name
expect(page.status_code).to eq(200)
text = PDF::Reader.new(StringIO.new(page.body)).pages.map(&:text).join(" ")
expect(text).to include("Ubicloud B.V.")
expect(text).to include("test-vm")
expect(text).to include("VAT (21%): (€5.68) $5.17")
end
it "show finalized invoice as PDF from EU issuer with reversed charge" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "DE"}, "metadata" => {"tax_id" => "123123123"}}).at_least(:once)
bi = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
invoice = InvoiceGenerator.new(bi.span.begin, bi.span.end, save_result: true, eur_rate: 1.1).run.first
visit "#{project.path}/billing"
expect(page).to have_content "VAT subject to reverse charge."
click_link invoice.name
expect(page.status_code).to eq(200)
text = PDF::Reader.new(StringIO.new(page.body)).pages.map(&:text).join(" ")
expect(text).to include("Ubicloud B.V.")
expect(text).to include("test-vm")
expect(text).to include("VAT subject to reverse charge")
end
it "show finalized invoice as PDF with old issuer info" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "address" => {"country" => "US"}, "metadata" => {"company_name" => "Foo Companye Name", "tax_id" => "123123123"}}).at_least(:once)
bi = billing_record(Time.parse("2023-06-01"), Time.parse("2023-07-01"))
invoice = InvoiceGenerator.new(bi.span.begin, bi.span.end, save_result: true, eur_rate: 1.1).run.first
invoice.content["issuer_info"].merge!("name" => nil, "tax_id" => "123123123", "in_eu_vat" => false)
invoice.modified!(:content)
invoice.save_changes
visit "#{project.path}/billing/invoice/#{invoice.ubid}"
expect(page.status_code).to eq(200)
text = PDF::Reader.new(StringIO.new(page.body)).pages.map(&:text).join(" ")
expect(text).to include("ACME Inc. - Foo Companye Name")
expect(text).to include("test-vm")
expect(text).to include("Tax ID: 123123123")
end
it "raises not found when invoice not exists" do
visit "#{project.path}/billing/invoice/08s56d4kaj94xsmrnf5v5m3mav"
expect(page.title).to eq("Ubicloud - ResourceNotFound")
expect(page.status_code).to eq(404)
expect(page).to have_content "ResourceNotFound"
end
end
describe "usage alerts" do
before do
UsageAlert.create_with_id(project_id: project.id, user_id: user.id, name: "alert-1", limit: 101)
UsageAlert.create_with_id(project_id: project_wo_permissions.id, user_id: user.id, name: "alert-2", limit: 100)
end
it "can list usage alerts" do
visit "#{project.path}/billing"
expect(page).to have_content "alert-1"
expect(page).to have_no_content "alert-2"
end
it "can create usage alert" do
visit "#{project.path}/billing"
fill_in "alert_name", with: "alert-3"
fill_in "limit", with: 200
click_button "Add"
expect(page).to have_content "alert-3"
end
it "can delete usage alert" do
visit "#{project.path}/billing"
expect(page).to have_content "alert-1"
# We send delete request manually instead of just clicking to button because delete action triggered by JavaScript.
# UI tests run without a JavaScript engine.
btn = find "#alert-#{project.usage_alerts.first.ubid} .delete-btn"
page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]}
visit "#{project.path}/billing"
expect(page).to have_flash_notice "Usage alert alert-1 is deleted."
visit "#{project.path}/billing"
expect(page).to have_no_content "alert-1"
end
it "returns 404 if usage alert not found" do
visit project.path + "/billing"
expect(page).to have_content "alert-1"
btn = find "#alert-#{project.usage_alerts.first.ubid} .delete-btn"
project.usage_alerts.first.destroy
page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]}
expect(page.status_code).to eq(404)
end
end
end
end