Files
ubicloud/lib/invoice_generator.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

211 lines
9.7 KiB
Ruby

# frozen_string_literal: true
require "time"
require "stripe"
class InvoiceGenerator
def initialize(begin_time, end_time, save_result: false, project_ids: [], eur_rate: nil)
@begin_time = begin_time
@end_time = end_time
@save_result = save_result
@project_ids = project_ids
@eur_rate = eur_rate
if @save_result && !@eur_rate
raise ArgumentError, "eur_rate must be provided when save_result is true"
end
end
def run
invoices = []
DB.transaction do
active_billing_records.group_by { |br| br[:project] }.each do |project, project_records|
project_content = {}
project_content[:project_id] = project.id
project_content[:project_name] = project.name
country = project.billing_info&.country
project_content[:billing_info] = project.billing_info&.stripe_data&.merge({
"id" => project.billing_info.id,
"ubid" => project.billing_info.ubid,
"in_eu_vat" => !!country&.in_eu_vat?
})
# Invoices are issued by Ubicloud Inc. for non-EU customers without VAT applied.
# Invoices are issued by Ubicloud B.V. for EU customers.
# - If the customer has provided a VAT number from the Netherlands, we charge 21% VAT.
# - If the customer has provided a VAT number from another European country, we include a reverse charge notice along with 0% VAT.
# - If the customer hasn't provided a VAT number, we charge 21% VAT until non-Dutch EU sales exceed annual threshold, than we charge local VAT.
issuer = {
name: "Ubicloud Inc.",
address: "310 Santa Ana Avenue",
country: "US",
city: "San Francisco",
state: "CA",
postal_code: "94127"
}
vat_info = nil
if country&.in_eu_vat?
issuer = {
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
}
vat_info = if (tax_id = project_content[:billing_info]["tax_id"]) && !tax_id.empty? && country.alpha2 != "NL"
{rate: 0, reversed: true}
else
{rate: Config.annual_non_dutch_eu_sales_exceed_threshold ? country.vat_rates["standard"] : 21, reversed: false, eur_rate: @eur_rate}
end
end
project_content[:issuer_info] = issuer
project_content[:resources] = []
project_content[:subtotal] = 0
project_records.group_by { |pr| [pr[:resource_id], pr[:resource_name]] }.each do |(resource_id, resource_name), line_items|
resource_content = {}
resource_content[:resource_id] = resource_id
resource_content[:resource_name] = resource_name
resource_content[:line_items] = []
resource_content[:cost] = 0
line_items.each do |li|
line_item_content = {}
line_item_content[:location] = li[:location]
line_item_content[:resource_type] = li[:resource_type]
line_item_content[:resource_family] = li[:resource_family]
line_item_content[:description] = BillingRate.line_item_description(li[:resource_type], li[:resource_family], li[:amount])
line_item_content[:amount] = li[:amount].to_f
line_item_content[:duration] = li[:duration]
line_item_content[:cost] = li[:cost].to_f
line_item_content[:begin_time] = li[:begin_time].utc
line_item_content[:unit_price] = li[:unit_price].to_f
resource_content[:line_items].push(line_item_content)
resource_content[:cost] += line_item_content[:cost]
end
project_content[:resources].push(resource_content)
project_content[:subtotal] += resource_content[:cost]
end
# We first apply discounts then credits, this is more beneficial for users as it
# would be possible to cover total cost with fewer credits.
project_content[:cost] = project_content[:subtotal]
project_content[:discount] = 0
if project.discount > 0
project_content[:discount] = (project_content[:cost] * (project.discount / 100.0)).round(3)
project_content[:cost] -= project_content[:discount]
end
project_content[:credit] = 0
if project.credit > 0
project_content[:credit] = [project_content[:cost], project.credit.to_f].min.round(3)
project_content[:cost] -= project_content[:credit]
end
# Each project have $1 github runner credit every month
# 1$ github credit won't be shown on the portal billing page for now.
github_usage = project_content[:resources].flat_map { it[:line_items] }.select { it[:resource_type] == "GitHubRunnerMinutes" }.sum { it[:cost] }
github_credit = [1.0, github_usage, project_content[:cost]].min
if github_credit > 0
project_content[:github_credit] = github_credit
project_content[:credit] += project_content[:github_credit]
project_content[:cost] -= project_content[:github_credit]
end
# Each project have some free AI inference tokens every month
# Free AI tokens WILL be shown on the portal billing page as a separate credit.
free_inference_tokens_remaining = FreeQuota.free_quotas["inference-tokens"]["value"]
free_inference_tokens_credit = 0.0
project_content[:resources]
.flat_map { it[:line_items] }
.select { it[:resource_type] == "InferenceTokens" }
.sort_by { |li| [li[:begin_time].to_date, -li[:unit_price]] }
.each do |li|
used_amount = [li[:amount], free_inference_tokens_remaining].min
free_inference_tokens_remaining -= used_amount
free_inference_tokens_credit += used_amount * li[:unit_price]
end
free_inference_tokens_credit = [free_inference_tokens_credit, project_content[:cost]].min
if free_inference_tokens_credit > 0
project_content[:free_inference_tokens_credit] = free_inference_tokens_credit
project_content[:cost] -= project_content[:free_inference_tokens_credit]
end
if project_content[:cost] < Config.minimum_invoice_charge_threshold
vat_info = nil
end
project_content[:vat_info] = vat_info
if vat_info && !vat_info[:reversed]
project_content[:vat_info][:amount] = (project_content[:cost] * vat_info[:rate].fdiv(100)).round(3)
project_content[:cost] += project_content[:vat_info][:amount]
end
project_content[:cost] = project_content[:cost].round(3)
if @save_result
invoice_month = @begin_time.strftime("%y%m")
invoice_customer = project.id[-10..]
invoice_order = format("%04d", project.invoices.count + 1)
invoice_number = "#{invoice_month}-#{invoice_customer}-#{invoice_order}"
invoice = Invoice.create(project_id: project.id, invoice_number: invoice_number, content: project_content, begin_time: @begin_time, end_time: @end_time)
# Don't substract the 1$ credit from customer's overall credit as it will be applied each month to each customer
project_content[:credit] -= project_content.fetch(:github_credit, 0)
if project_content[:credit] > 0
# We don't use project.credit here, because credit might get updated between
# the time we read and write. Referencing credit column here prevents such
# race conditions. If credit got increased, then there is no problem. If it
# got decreased, CHECK constraint in the DB will prevent credit balance to go
# negative.
# We also need to disable Sequel validations, because Sequel simplychecks if
# the new value is BigDecimal, but "Sequel[:credit] - project_content[:credit]" expression
# is Sequel::SQL::NumericExpression, not BigDecimal. Eventhough it resolves to
# BigDecimal, it fails the check.
# Finally, we use save_changes instead of update because it is not possible to
# pass validate: false to update.
project.credit = Sequel[:credit] - project_content[:credit].round(3)
project.save_changes(validate: false)
end
else
invoice = Invoice.new(project_id: project.id, content: JSON.parse(project_content.to_json), begin_time: @begin_time, end_time: @end_time, created_at: Time.now, status: "current")
end
invoices.push(invoice)
end
end
invoices
end
def active_billing_records
active_billing_records = BillingRecord.eager(project: [:billing_info, :invoices])
.where { |br| Sequel.pg_range(br.span).overlaps(Sequel.pg_range(@begin_time...@end_time)) }
active_billing_records = active_billing_records.where(project_id: @project_ids) unless @project_ids.empty?
active_billing_records.all.map do |br|
# We cap the billable duration at 672 hours. In this way, we can
# charge the users same each month no matter the number of days
# in that month.
duration = [672 * 60, br.duration(@begin_time, @end_time).ceil].min
{
project: br.project,
resource_id: br.resource_id,
location: br.billing_rate["location"],
resource_name: br.resource_name,
resource_type: br.billing_rate["resource_type"],
resource_family: br.billing_rate["resource_family"],
amount: br.amount,
cost: (br.amount * duration * br.billing_rate["unit_price"]).round(3),
duration: duration,
begin_time: br.span.begin,
unit_price: br.billing_rate["unit_price"]
}
end
end
end