Previously, access control was implemented via AccessPolicy and {Access/AppliedTag}. This doesn't remove AccessPolicy/AppliedTag yet, as there are still references to them. They will be removed in a later commit. In order for the new access control to work, the authorization methods need to be passed the project_id, to force the access query to operate on a single project. That part isn't very invasive, since authorization is mostly centralized in helpers/general.rb, but it did require a couple other places to be changed: * model/invoice.rb is the only caller of Authorization.authorize outside of the Clover helpers. This is probably a layer violation that should be fixed later, but for now, add the project_id manually to the call. * In the github route, set @project so that the helpers can access it. Add Project#dissociate_subject, which handles remove the subject tags and direct access control entries when removing a user or token from a project. Call this when removing uses or tokens from a project. Remove Authorization.authorized_resources_dataset, as it was only needed by Dataset#authorize. This inlines the code into Dataset#authorize. This was done because it doesn't make sense to use the method in isolation, and the query needed in Dataset#authorize depends on the dataset, which isn't passed to Authorization.authorized_resources_dataset. Remove Authorization.expand_actions. This method has no equivalent in the new design, and it is no longer needed. For the api specs that use a personal access token, this adds the token to the Admin subject tag (which has full access). This has similar behavior as applying the admin managed policy had previously. The authorization queries do not currently support nested tags. Support for nested tags will be added later. While here, fix parameter name for Clover#all_permissions. This method is only currently called with @project.id as the argument, so it doesn't technically need an argument, but it could potentially be used later to get all permissions on an object other than the current project.
247 lines
9.7 KiB
Ruby
247 lines
9.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "../model"
|
|
require "stripe"
|
|
require "prawn"
|
|
require "prawn/table"
|
|
|
|
class Invoice < Sequel::Model
|
|
many_to_one :project
|
|
|
|
include ResourceMethods
|
|
|
|
def path
|
|
"/invoice/#{id ? ubid : "current"}"
|
|
end
|
|
|
|
def name
|
|
begin_time.strftime("%B %Y")
|
|
end
|
|
|
|
def charge
|
|
reload # Reload to get the latest status to avoid double charging
|
|
unless (Stripe.api_key = Config.stripe_secret_key)
|
|
Clog.emit("Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing.")
|
|
return true
|
|
end
|
|
|
|
if status != "unpaid"
|
|
Clog.emit("Invoice already charged.") { {invoice_already_charged: {ubid: ubid, status: status}} }
|
|
return true
|
|
end
|
|
|
|
amount = content["cost"].to_f.round(2)
|
|
if amount < Config.minimum_invoice_charge_threshold
|
|
update(status: "below_minimum_threshold")
|
|
Clog.emit("Invoice cost is less than minimum charge cost.") { {invoice_below_threshold: {ubid: ubid, cost: amount}} }
|
|
send_success_email(below_threshold: true)
|
|
return true
|
|
end
|
|
|
|
if (billing_info = BillingInfo[content.dig("billing_info", "id")]).nil? || billing_info.payment_methods.empty?
|
|
Clog.emit("Invoice doesn't have billing info.") { {invoice_no_billing: {ubid: ubid}} }
|
|
return false
|
|
end
|
|
|
|
errors = []
|
|
billing_info.payment_methods_dataset.order(:order).each do |pm|
|
|
begin
|
|
payment_intent = Stripe::PaymentIntent.create({
|
|
amount: (amount * 100).to_i, # 100 cents to charge $1.00
|
|
currency: "usd",
|
|
confirm: true,
|
|
off_session: true,
|
|
customer: billing_info.stripe_id,
|
|
payment_method: pm.stripe_id
|
|
})
|
|
rescue Stripe::CardError => e
|
|
Clog.emit("Invoice couldn't charged.") { {invoice_not_charged: {ubid: ubid, payment_method: pm.ubid, error: e.message}} }
|
|
errors << e.message
|
|
next
|
|
end
|
|
|
|
unless payment_intent.status == "succeeded"
|
|
Clog.emit("BUG: payment intent should succeed here") { {invoice_not_charged: {ubid: ubid, payment_method: pm.ubid, intent_id: payment_intent.id, error: payment_intent.status}} }
|
|
next
|
|
end
|
|
|
|
Clog.emit("Invoice charged.") { {invoice_charged: {ubid: ubid, payment_method: pm.ubid, cost: amount}} }
|
|
self.status = "paid"
|
|
content.merge!({
|
|
"payment_method" => {
|
|
"id" => pm.id,
|
|
"stripe_id" => pm.stripe_id
|
|
},
|
|
"payment_intent" => payment_intent.id
|
|
})
|
|
save(columns: [:status, :content])
|
|
project.update(reputation: "verified") if amount > 5
|
|
|
|
send_success_email
|
|
return true
|
|
end
|
|
|
|
Clog.emit("Invoice couldn't charged with any payment method.") { {invoice_not_charged: {ubid: ubid}} }
|
|
send_failure_email(errors)
|
|
false
|
|
end
|
|
|
|
def send_success_email(below_threshold: false)
|
|
ser = Serializers::Invoice.serialize(self, {detailed: true})
|
|
messages = if below_threshold
|
|
["Since the invoice total of #{ser[:total]} is below our minimum charge threshold, there will be no charges for this month."]
|
|
else
|
|
["The invoice amount of #{ser[:total]} will be debited from your credit card on file."]
|
|
end
|
|
github_usage = ser[:items].select { _1[:description].include?("GitHub Runner") }.sum { _1[:cost] }
|
|
saved_amount = 9 * github_usage
|
|
if saved_amount > 1
|
|
messages << "You saved $#{saved_amount.to_i} this month using managed Ubicloud runners instead of GitHub hosted runners!"
|
|
end
|
|
|
|
Util.send_email(ser[:billing_email], "Ubicloud #{ser[:name]} Invoice ##{ser[:invoice_number]}",
|
|
greeting: "Dear #{ser[:billing_name]},",
|
|
body: ["Please find your current invoice ##{ser[:invoice_number]} below.",
|
|
*messages,
|
|
"If you have any questions, please send us a support request via support@ubicloud.com, and include your invoice number."],
|
|
button_title: "View Invoice",
|
|
button_link: "#{Config.base_url}#{project.path}/billing#{ser[:path]}",
|
|
attachments: [["#{ser[:filename]}.pdf", generate_pdf(ser)]])
|
|
end
|
|
|
|
def send_failure_email(errors)
|
|
ser = Serializers::Invoice.serialize(self, {detailed: true})
|
|
receivers = [ser[:billing_email]]
|
|
receivers += project.accounts.select { Authorization.has_permission?(project.id, _1.id, "Project:billing", project.id) }.map(&:email)
|
|
Util.send_email(receivers.uniq, "Urgent: Action Required to Prevent Service Disruption",
|
|
cc: Config.mail_from,
|
|
greeting: "Dear #{ser[:billing_name]},",
|
|
body: ["We hope this message finds you well.",
|
|
"We've noticed that your credit card on file has been declined with the following errors:",
|
|
*errors.map { "- #{_1}" },
|
|
"The invoice amount of #{ser[:total]} tried be debited from your credit card on file.",
|
|
"To prevent service disruption, please update your payment information within the next two days.",
|
|
"If you have any questions, please send us a support request via support@ubicloud.com."],
|
|
button_title: "Update Payment Method",
|
|
button_link: "#{Config.base_url}#{project.path}/billing")
|
|
end
|
|
|
|
def generate_pdf(data)
|
|
pdf = Prawn::Document.new(
|
|
page_size: "A4",
|
|
page_layout: :portrait,
|
|
info: {Title: data[:filename], Creator: "Ubicloud", reationDate: created_at}
|
|
)
|
|
# We use external fonts to support all UTF-8 characters
|
|
pdf.font_families.update(
|
|
"BeVietnamPro" => {
|
|
normal: "assets/font/BeVietnamPro/Regular.ttf",
|
|
semibold: "assets/font/BeVietnamPro/SemiBold.ttf"
|
|
}
|
|
)
|
|
pdf.font "BeVietnamPro"
|
|
|
|
column_width = (pdf.bounds.width / 2) - 10
|
|
right_column_x = column_width + 20
|
|
dark_gray = "1F2937" # Tailwind text-gray-800
|
|
light_gray = "6B7280" # Tailwind text-gray-500
|
|
|
|
pdf.fill_color light_gray
|
|
|
|
# Row 1, Left Column: Logo and issuer information
|
|
row_y = pdf.bounds.top
|
|
row = pdf.bounding_box([0, row_y], width: column_width) do
|
|
path = "public/logo-primary.png"
|
|
pdf.image path, height: 25, position: :left
|
|
pdf.move_down 10
|
|
# :nocov:
|
|
pdf.text data[:issuer_name], style: :semibold, color: dark_gray if data[:issuer_name]
|
|
# :nocov:
|
|
pdf.text "#{data[:issuer_address]},"
|
|
pdf.text "#{data[:issuer_city]}, #{data[:issuer_state]} #{data[:issuer_postal_code]},"
|
|
pdf.text data[:issuer_country]
|
|
end
|
|
|
|
# Row 1, Right Column: Invoice name and number
|
|
pdf.bounding_box([right_column_x, row_y], width: column_width) do
|
|
pdf.text "Invoice for #{data[:name]}", align: :right, style: :semibold, color: dark_gray, size: 18
|
|
pdf.text "##{data[:invoice_number]}", align: :right
|
|
end
|
|
pdf.move_down row.height.to_i - 20
|
|
|
|
# Row 2, Left Column: Billing information
|
|
row_y = pdf.cursor
|
|
row = pdf.bounding_box([0, row_y], width: column_width) do
|
|
pdf.text "Bill to:", style: :semibold, color: dark_gray, size: 14
|
|
pdf.text [data[:billing_name], data[:company_name]].compact.join(" - "), style: :semibold, color: dark_gray, size: 14
|
|
pdf.move_down 5
|
|
# :nocov:
|
|
pdf.text "Tax ID: #{data[:tax_id]}" if data[:tax_id]
|
|
# :nocov:
|
|
pdf.text "#{data[:billing_address]},"
|
|
pdf.text "#{data[:billing_city]}, #{data[:billing_state]} #{data[:billing_postal_code]},"
|
|
pdf.text data[:billing_country]
|
|
end
|
|
|
|
# Row 2, Right Column: Invoice dates
|
|
pdf.bounding_box([right_column_x, row_y], width: column_width) do
|
|
dates = [["Invoice date:", data[:date]], ["Due date:", data[:date]]]
|
|
pdf.table(dates, position: :right) do
|
|
style(row(0..1).columns(0..1), padding: [2, 5, 2, 5], borders: [])
|
|
style(column(0), align: :right, font_style: :semibold, text_color: dark_gray)
|
|
style(column(1), align: :right)
|
|
end
|
|
end
|
|
pdf.move_down row.height.to_i - 20
|
|
|
|
# Row 3: Invoice items
|
|
items = [["RESOURCE", "DESCRIPTION", "USAGE", "AMOUNT"]]
|
|
items += if data[:items].empty?
|
|
[[{content: "No resources", colspan: 4, align: :center, font_style: :semibold}]]
|
|
else
|
|
data[:items].map { [_1[:name], _1[:description], _1[:usage], _1[:cost_humanized]] }
|
|
end
|
|
pdf.table items, header: true, width: pdf.bounds.width, cell_style: {size: 9, border_color: "E5E7EB", borders: [], padding: [5, 6, 12, 6], valign: :center} do
|
|
style(row(0), size: 12, font_style: :semibold, text_color: dark_gray, background_color: "F9FAFB")
|
|
style(column(0), text_color: dark_gray)
|
|
style(columns(-2..-1), align: :right)
|
|
style(column(0), borders: [:left, :top, :bottom])
|
|
style(column(-1), borders: [:right, :top, :bottom], width: 70)
|
|
style(columns(1..-2), borders: [:top, :bottom])
|
|
end
|
|
pdf.move_down 10
|
|
|
|
# Row 4: Totals
|
|
totals = [
|
|
["Subtotal:", data[:subtotal]],
|
|
# :nocov:
|
|
(data[:discount] != "$0.00") ? ["Discount:", "-#{data[:discount]}"] : nil,
|
|
(data[:credit] != "$0.00") ? ["Credit:", "-#{data[:credit]}"] : nil,
|
|
# :nocov:
|
|
["Total:", data[:total]]
|
|
].compact
|
|
pdf.table(totals, position: :right, cell_style: {padding: [2, 5, 2, 5], borders: []}) do
|
|
style(column(0), align: :right, font_style: :semibold, text_color: dark_gray)
|
|
style(column(1), align: :right)
|
|
end
|
|
|
|
pdf.render
|
|
end
|
|
end
|
|
|
|
Invoice.unrestrict_primary_key
|
|
|
|
# Table: invoice
|
|
# Columns:
|
|
# id | uuid | PRIMARY KEY
|
|
# project_id | uuid | NOT NULL
|
|
# content | jsonb | NOT NULL
|
|
# created_at | timestamp with time zone | NOT NULL DEFAULT now()
|
|
# invoice_number | text | NOT NULL
|
|
# begin_time | timestamp with time zone | NOT NULL
|
|
# end_time | timestamp with time zone | NOT NULL
|
|
# status | text | NOT NULL DEFAULT 'unpaid'::text
|
|
# Indexes:
|
|
# invoice_pkey | PRIMARY KEY btree (id)
|
|
# invoice_project_id_index | btree (project_id)
|