Files
ubicloud/spec/lib/invoice_generator_spec.rb
Jeremy Evans e456211097 Make specs work when database timezone doesn't match local timezone
In the invoice generator, always use UTC for the begin_time.
2025-02-19 10:25:31 -08:00

322 lines
17 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)["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)
expect(invoices.count).to eq(1)
br = BillingRate.from_resource_properties("VmVCpu", vm.family, vm.location)
duration_mins = [672 * 60, (duration / 60).ceil].min
cost = (vm.vcpus * duration_mins * br["unit_price"]).round(3)
expect(invoices.first.content).to eq({
"project_id" => project.id,
"project_name" => project.name,
"billing_info" => project.billing_info ? {
"id" => project.billing_info.id,
"ubid" => project.billing_info.ubid,
"name" => "ACME Inc.",
"email" => nil,
"address" => "",
"country" => "NL",
"city" => nil,
"state" => nil,
"postal_code" => nil,
"tax_id" => "123456",
"company_name" => nil
} : nil,
"issuer_info" => {
"name" => "Ubicloud Inc.",
"address" => "310 Santa Ana Avenue",
"country" => "US",
"city" => "San Francisco",
"state" => "CA",
"postal_code" => "94127"
},
"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" => cost,
"begin_time" => begin_time.utc.to_s,
"unit_price" => br["unit_price"]
}],
"cost" => cost
}],
"subtotal" => cost,
"discount" => 0,
"credit" => 0,
"cost" => cost
})
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: "hetzner-fsn1").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: "loc", 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 "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
it "generates invoice for project with billing info" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key").at_least(:once)
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {"tax_id" => "123456"}, "address" => {"country" => "NL"}}).at_least(:once)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time - 90 * day, nil))
bi = BillingInfo.create_with_id(stripe_id: "cs_1234567890")
p1.update(billing_info_id: bi.id)
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 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).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).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).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).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).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).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).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).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: "loc", 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).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: "loc", 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).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