Files
ubicloud/spec/lib/invoice_generator_spec.rb
Jeremy Evans 4b819d3cb2 Change all create_with_id to create
It hasn't been necessary to use create_with_id since
ebc79622df, in December 2024.

I have plans to introduce:

```ruby
def create_with_id(id, values)
  obj = new(values)
  obj.id = id
  obj.save_changes
end
```

This will make it easier to use the same id when creating
multiple objects.  The first step is removing the existing
uses of create_with_id.
2025-08-06 01:55:51 +09:00

408 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", gr.label_data["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(
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(email: "auth1@example.com")
Project.create(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) { Prog::Vnet::LoadBalancerNexus.assemble(ps.id, name: "dummy-lb-1", src_port: 80, dst_port: 80, health_check_endpoint: "/up") }
let(:ie1) { InferenceEndpoint.create(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.utc(2023, 6) }
let(:end_time) { Time.utc(2023, 7) }
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(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(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(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(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(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(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
it "charges no VAT if the total is less than minimum invoice charge threshold" do
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(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time, begin_time + 0.5 * day), 2)
invoices = described_class.new(begin_time, end_time).run
expect(invoices.first.content["vat_info"]).to be_nil
end
it "charges no VAT if the address is missing" do
expect(Stripe::Customer).to receive(:retrieve).with("cs_1234567890").and_return({"name" => "ACME Inc.", "metadata" => {}, "address" => nil}).at_least(:once)
p1.update(billing_info_id: BillingInfo.create(stripe_id: "cs_1234567890").id)
generate_billing_record(p1, vm1, Sequel::Postgres::PGRange.new(begin_time, begin_time + 0.5 * day), 2)
invoices = described_class.new(begin_time, end_time).run
expect(invoices.first.content["vat_info"]).to be_nil
end
end
it "generates invoice for a single project" do
p2 = Project.create(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(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(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(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(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(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