Files
ubicloud/lib/invoice_generator.rb
Enes Cakir 0d245a24f8 Do not calculate VAT if the total is below the minimum charge threshold
If the total is below the minimum charge threshold, we don’t charge the
customer. We won’t collect VAT for it, so there’s no need to calculate
or show it on the invoice.

I moved the VAT assignment to after the cost calculation since we need
the total cost to decide whether to calculate it or not.
2025-04-02 08:37:22 +03: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 { _1[:line_items] }.select { _1[:resource_type] == "GitHubRunnerMinutes" }.sum { _1[: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 { _1[:line_items] }
.select { _1[: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_with_id(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