Files
ubicloud/spec/lib/invoice_generator_spec.rb
Furkan Sahin 12dbeb57a1 Update location references with foreign key in the controlplane
We are basically updating the location references everywhere with a
location id and adding the location relationship to the models to be
able to fetch location names when needed.
This also makes the LocationNameConverter model obsolete, so we are
removing it.

Use model id as value for Sequel::Model in resource creation form

Use id of the location as preselected value in Postgres update form
2025-03-23 15:48:19 +01:00

390 lines
22 KiB
Ruby

# frozen_string_literal: true
# rubocop:disable RSpec/NoExpectationExample
RSpec.describe InvoiceGenerator do
def generate_billing_record(project, resource, span, amount = 5000)
case resource
when Vm
vm = resource
billing_rate_id = BillingRate.from_resource_properties("VmVCpu", vm.family, vm.location.name)["id"]
amount = vm.vcpus
name = vm.name
when GithubRunner
gr = resource
billing_rate_id = BillingRate.from_resource_properties("GitHubRunnerMinutes", Github.runner_labels[gr.label]["vm_size"], "global")["id"]
name = "foo"
when InferenceEndpoint
billing_rate_id = BillingRate.from_resource_properties("InferenceTokens", resource.model_name, "global")["id"]
name = resource.name
end
BillingRecord.create_with_id(
project_id: project.id,
resource_id: resource.id,
resource_name: name,
span: span,
billing_rate_id: billing_rate_id,
amount: amount
)
end
def check_invoice_for_single_vm(invoices, project, vm, duration, begin_time, expected_vat_info: nil)
expect(invoices.count).to eq(1)
expected_issuer = if expected_vat_info
{
"name" => "Ubicloud B.V.",
"address" => "Turfschip 267",
"country" => "NL",
"city" => "Amstelveen",
"postal_code" => "1186 XK",
"tax_id" => "NL864651442B01",
"trade_id" => "88492729",
"in_eu_vat" => true
}
else
{
"name" => "Ubicloud Inc.",
"address" => "310 Santa Ana Avenue",
"country" => "US",
"city" => "San Francisco",
"state" => "CA",
"postal_code" => "94127"
}
end
expected_billing_info = project.billing_info&.stripe_data&.merge({
"id" => project.billing_info.id,
"ubid" => project.billing_info.ubid,
"in_eu_vat" => !!expected_vat_info
})
br = BillingRate.from_resource_properties("VmVCpu", vm.family, vm.location.name)
duration_mins = [672 * 60, (duration / 60).ceil].min
expected_cost = (vm.vcpus * duration_mins * br["unit_price"]).round(3)
expected_resources = [{
"resource_id" => vm.id,
"resource_name" => vm.name,
"line_items" => [{
"location" => br["location"],
"resource_type" => "VmVCpu",
"resource_family" => vm.family,
"description" => "standard-#{vm.vcpus} Virtual Machine",
"amount" => vm.vcpus.to_f,
"duration" => duration_mins,
"cost" => expected_cost,
"begin_time" => begin_time.utc.to_s,
"unit_price" => br["unit_price"]
}],
"cost" => expected_cost
}]
actual_content = invoices.first.content
[
["project_id", project.id],
["project_name", project.name],
["billing_info", expected_billing_info],
["issuer_info", expected_issuer],
["vat_info", expected_vat_info],
["resources", expected_resources],
["subtotal", expected_cost],
["discount", 0],
["credit", 0],
["cost", (expected_cost + expected_vat_info&.fetch("amount", 0).to_f).round(3)]
].each do |key, expected|
expect(actual_content[key]).to eq(expected)
end
end
let(:p1) {
Account.create_with_id(email: "auth1@example.com")
Project.create_with_id(name: "cool-project")
}
let(:vm1) { create_vm }
let(:ps) { Prog::Vnet::SubnetNexus.assemble(p1.id, name: "dummy-ps-1", location_id: Location::HETZNER_FSN1_ID).subject }
let(:lb) { LoadBalancer.create_with_id(private_subnet_id: ps.id, name: "dummy-lb-1", src_port: 80, dst_port: 80, health_check_endpoint: "/up", project_id: p1.id) }
let(:ie1) { InferenceEndpoint.create_with_id(name: "ie1", model_name: "test-model", project_id: p1.id, is_public: true, visible: true, location_id: Location::HETZNER_FSN1_ID, vm_size: "size", replica_count: 1, boot_image: "image", storage_volumes: [], engine_params: "", engine: "vllm", private_subnet_id: ps.id, load_balancer_id: lb.id) }
let(:day) { 24 * 60 * 60 }
let(:begin_time) { Time.parse("2023-06-01") }
let(:end_time) { Time.parse("2023-07-01") }
it "fails if eur_rate not provided while saving result" do
expect { described_class.new(begin_time, end_time, save_result: true).run }.to raise_error(ArgumentError, "eur_rate must be provided when save_result is true")
end
it "does not generate invoice for billing record that started and terminated before this billing window" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 150 * day, begin_time - 90 * day))
invoices = described_class.new(begin_time, end_time).run
expect(invoices.count).to eq(0)
end
it "generates invoice for billing record started before this billing window and not terminated yet" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day)
end
it "generates invoice for billing record started before this billing window and terminated in the future" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day)
end
it "generates invoice for billing record started before this billing window and terminated before end of it" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, begin_time + 15 * day))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 15 * day, begin_time - 90 * day)
end
it "generates invoice for billing record started in this billing window and not terminated yet" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time + 5 * day, nil))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 25 * day, begin_time + 5 * day)
end
it "generates invoice for billing record started in this billing window and terminated in the future" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time + 5 * day, end_time + 90 * day))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 25 * day, begin_time + 5 * day)
end
it "generates invoice for billing record started in this billing window and terminated before end of it" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time + 5 * day, begin_time + 15 * day))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 10 * day, begin_time + 5 * day)
end
it "does not generate invoice for billing record started in a future billing window" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(end_time + 5 * day, end_time + 15 * day))
invoices = described_class.new(begin_time, end_time).run
expect(invoices.count).to eq(0)
end
context "when project has billing info" do
before do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key").at_least(:once)
end
it "charges no VAT for non-EU customer" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {"tax_id" => "123456"}, "address" => {"line1" => "123 Main St", "country" => "US"}}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create_with_id(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day)
end
it "charges 21% VAT for Dutch customer with tax id" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {"tax_id" => "123456"}, "address" => {"line1" => "123 Main St", "country" => "NL"}}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create_with_id(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time, eur_rate: 1.1).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day, expected_vat_info: {"amount" => 5.166, "eur_rate" => 1.1, "rate" => 21, "reversed" => false})
end
it "charges 21% VAT for Dutch customer without tax id" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {}, "address" => {"line1" => "123 Main St", "country" => "NL"}}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create_with_id(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time, eur_rate: 1.1).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day, expected_vat_info: {"amount" => 5.166, "eur_rate" => 1.1, "rate" => 21, "reversed" => false})
end
it "reverse charges VAT for non-Dutch EU customer with tax id" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {"tax_id" => "123456"}, "address" => {"line1" => "123 Main St", "country" => "DE"}}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create_with_id(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day, expected_vat_info: {"rate" => 0, "reversed" => true})
end
it "charges 21% VAT for non-Dutch EU customer without tax id until threshold" do
expect(Config).to receive(:annual_non_dutch_eu_sales_exceed_threshold).and_return(false)
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {}, "address" => {"line1" => "123 Main St", "country" => "DE"}}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create_with_id(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time, eur_rate: 1.1).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day, expected_vat_info: {"amount" => 5.166, "eur_rate" => 1.1, "rate" => 21, "reversed" => false})
end
it "charges local VAT for non-Dutch EU customer without tax id if threshold exceeds" do
expect(Config).to receive(:annual_non_dutch_eu_sales_exceed_threshold).and_return(true)
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {}, "address" => {"line1" => "123 Main St", "country" => "DE"}}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create_with_id(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
invoices = described_class.new(begin_time, end_time, eur_rate: 1.1).run
check_invoice_for_single_vm(invoices, p1, vm1, 30 * day, begin_time - 90 * day, expected_vat_info: {"amount" => 4.674, "eur_rate" => 1.1, "rate" => 19, "reversed" => false})
end
end
it "generates invoice for a single project" do
p2 = Project.create_with_id(name: "cool-project")
vm2 = create_vm
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time, end_time))
generate_billing_record(p2, vm2, Sequel::Postgres::PGRange.new(begin_time, end_time))
invoices = described_class.new(begin_time, end_time, project_ids: [p1.id]).run
expect(invoices.count).to eq(1)
end
it "creates invoice record in the database only if save_result is set" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
described_class.new(begin_time, end_time, save_result: false).run
expect(Invoice.count).to eq(0)
described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run
expect(Invoice.count).to eq(1)
expect(Invoice.first.invoice_number).to eq("#{begin_time.strftime("%y%m")}-#{p1.id[-10..]}-0001")
end
it "handles discounts" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
cost_before, discount_before = described_class.new(begin_time, end_time).run.first.content.values_at("cost", "discount")
p1.update(discount: 10)
cost_after, discount_after = described_class.new(begin_time, end_time).run.first.content.values_at("cost", "discount")
expect(cost_after).to eq((cost_before * 0.9).round(3))
expect(discount_before).to eq(0)
expect(discount_after).to eq((cost_before * 0.1).round(3))
end
it "handles credits" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
cost_before, credit_before = described_class.new(begin_time, end_time).run.first.content.values_at("cost", "credit")
p1.update(credit: 10)
cost_after, credit_after = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content.values_at("cost", "credit")
expect(cost_after).to eq((cost_before - 10).round(3))
expect(credit_before).to eq(0)
expect(credit_after).to eq(10)
expect(p1.reload.credit).to eq(0)
end
it "handles discounts and credits at the same time" do
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
before = described_class.new(begin_time, end_time).run.first.content
p1.update(credit: 10, discount: 10)
after = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
expect(after["cost"]).to eq((before["cost"] * 0.9 - 10).round(3))
expect(after["discount"]).to eq((before["cost"] * 0.1).round(3))
expect(after["credit"]).to eq(10)
expect(p1.reload.credit).to eq(0)
end
it "handles github runner credit only" do
github_runner = GithubRunner.create_with_id(label: "ubicloud", repository_name: "my-repo")
generate_billing_record(p1, github_runner, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
invoice = described_class.new(begin_time, end_time).run.first.content
expect(invoice["cost"]).to eq(invoice["subtotal"] - 1)
expect(invoice["credit"]).to eq(1)
expect(p1.reload.credit).to eq(0)
end
it "handles project and github runner credits together" do
github_runner = GithubRunner.create_with_id(label: "ubicloud", repository_name: "my-repo")
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
generate_billing_record(p1, github_runner, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
before = described_class.new(begin_time, end_time).run.first.content
p1.update(credit: 10, discount: 10)
after = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
expect(before["cost"]).to eq(before["subtotal"] - 1)
expect(after["cost"]).to eq((before["subtotal"] * 0.9 - 11).round(3))
expect(after["discount"]).to eq((before["subtotal"] * 0.1).round(3))
expect(after["credit"]).to eq(11)
expect(p1.reload.credit).to eq(0)
end
it "handles full discount and github runner credits together" do
github_runner = GithubRunner.create_with_id(label: "ubicloud", repository_name: "my-repo")
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
generate_billing_record(p1, github_runner, Sequel::Postgres::PGRange.new(begin_time - 90 * day, end_time + 90 * day))
p1.update(credit: 0, discount: 100)
invoice = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
expect(invoice["cost"]).to eq(0)
end
it "handles inference quota when not used up" do
generate_billing_record(p1, ie1, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time, begin_time.to_date.to_time + day), 100000)
invoice = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
billing_rate = BillingRate.from_resource_properties("InferenceTokens", ie1.model_name, "global")["unit_price"]
expect(invoice["free_inference_tokens_credit"]).to eq(100000 * billing_rate)
expect(invoice["cost"]).to eq(0)
end
it "handles inference quota when used up" do
generate_billing_record(p1, ie1, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time + day, begin_time.to_date.to_time + 2 * day), 600000)
invoice = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
free_inference_tokens = FreeQuota.free_quotas["inference-tokens"]["value"]
billing_rate = BillingRate.from_resource_properties("InferenceTokens", ie1.model_name, "global")["unit_price"]
expect(free_inference_tokens).to eq(500000)
expect(billing_rate).to eq(0.0000000500)
expect(invoice["free_inference_tokens_credit"]).to eq(free_inference_tokens * billing_rate)
expect(invoice["cost"]).to eq((600000 - free_inference_tokens) * billing_rate)
end
it "handles inference quota and project credit together" do
generate_billing_record(p1, ie1, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time + day, begin_time.to_date.to_time + 2 * day), 60000000)
before = described_class.new(begin_time, end_time).run.first.content
p1.update(credit: 1, discount: 10)
after = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
free_inference_tokens = FreeQuota.free_quotas["inference-tokens"]["value"]
billing_rate = BillingRate.from_resource_properties("InferenceTokens", ie1.model_name, "global")["unit_price"]
expect(before["free_inference_tokens_credit"]).to eq(free_inference_tokens * billing_rate)
expect(before["discount"]).to eq(0)
expect(before["credit"]).to eq(0)
expect(before["cost"]).to eq(((60000000 - free_inference_tokens) * billing_rate).round(3))
expect(after["free_inference_tokens_credit"]).to eq(free_inference_tokens * billing_rate)
expect(after["discount"]).to eq((billing_rate * 60000000 * 0.1).round(3))
expect(after["credit"]).to eq(1)
expect(after["cost"]).to eq((60000000 * billing_rate * 0.9 - free_inference_tokens * billing_rate - 1).round(3))
expect(p1.reload.credit).to eq(0)
end
it "handles inference quota with two different models on the same day" do
ie2 = InferenceEndpoint.create_with_id(name: "ie2", model_name: "test-model2", project_id: p1.id, is_public: true, visible: true, location_id: Location::HETZNER_FSN1_ID, vm_size: "size", replica_count: 1, boot_image: "image", storage_volumes: [], engine_params: "", engine: "vllm", private_subnet_id: ps.id, load_balancer_id: lb.id)
generate_billing_record(p1, ie1, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time, begin_time.to_date.to_time + day), 100000)
generate_billing_record(p1, ie2, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time, begin_time.to_date.to_time + day), 800000)
invoice = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
free_inference_tokens = FreeQuota.free_quotas["inference-tokens"]["value"]
billing_rate1 = BillingRate.from_resource_properties("InferenceTokens", ie1.model_name, "global")["unit_price"]
billing_rate2 = BillingRate.from_resource_properties("InferenceTokens", ie2.model_name, "global")["unit_price"]
expect(billing_rate1).to eq(0.0000000500)
expect(billing_rate2).to eq(0.0000002000)
expect(invoice["free_inference_tokens_credit"]).to eq(free_inference_tokens * billing_rate2)
expect(invoice["cost"]).to eq((800000 - free_inference_tokens) * billing_rate2 + 100000 * billing_rate1)
expect(invoice["resources"].count).to eq(2)
end
it "handles inference quota with two different models on different days" do
ie2 = InferenceEndpoint.create_with_id(name: "ie2", model_name: "test-model2", project_id: p1.id, is_public: true, visible: true, location_id: Location::HETZNER_FSN1_ID, vm_size: "size", replica_count: 1, boot_image: "image", storage_volumes: [], engine_params: "", engine: "vllm", private_subnet_id: ps.id, load_balancer_id: lb.id)
generate_billing_record(p1, ie1, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time, begin_time.to_date.to_time + day), 100000)
generate_billing_record(p1, ie2, Sequel::Postgres::PGRange.new(begin_time.to_date.to_time + 2 * day, begin_time.to_date.to_time + 3 * day), 800000)
invoice = described_class.new(begin_time, end_time, save_result: true, eur_rate: 1.1).run.first.content
free_inference_tokens = FreeQuota.free_quotas["inference-tokens"]["value"]
billing_rate1 = BillingRate.from_resource_properties("InferenceTokens", ie1.model_name, "global")["unit_price"]
billing_rate2 = BillingRate.from_resource_properties("InferenceTokens", ie2.model_name, "global")["unit_price"]
expect(invoice["free_inference_tokens_credit"]).to eq(100000 * billing_rate1 + (free_inference_tokens - 100000) * billing_rate2)
expect(invoice["cost"]).to eq((800000 - (free_inference_tokens - 100000)) * billing_rate2)
expect(invoice["resources"].count).to eq(2)
end
end
# rubocop:enable RSpec/NoExpectationExample