ubicloud/model/invoice.rb
Enes Cakir 8f286b147a Add Pay Now button to unpaid invoices
Unfortunately, credit cards sometimes encounter issues with offline
recurring payments because they require 3D Secure. Several customers
have experienced this problem.

Currently, we manually create payment links for them after the invoice
is finalized at the start of the month. Once they complete the payment,
we mark the invoice as paid.

To automate this, I've added a "Pay Now" button to unpaid invoices.
Clicking it creates a checkout session and redirects the customer to
Stripe’s hosted payment page. This uses logic similar to adding credit
cards, but instead of "setup" mode, we now use "payment" mode. On the
success URL, we check the status of the session and the invoice ID to
mark the invoice as paid.
2025-10-01 18:52:48 +03:00

324 lines
12 KiB
Ruby

# frozen_string_literal: true
require_relative "../model"
require "aws-sdk-s3"
require "countries"
require "prawn"
require "prawn/table"
require "stripe"
class Invoice < Sequel::Model
unrestrict_primary_key
many_to_one :project
plugin ResourceMethods
def path_id
id ? ubid : "current"
end
def path
"/invoice/#{path_id}"
end
def filename
"Ubicloud-#{begin_time.strftime("%Y-%m")}-#{invoice_number}.pdf"
end
%i[subtotal cost].each do |meth|
str = meth.to_s
define_method(meth) { content[str] }
end
def payable?
cost > 0 && status == "unpaid" && ubid != "current"
end
def blob_key
group = if status == "below_minimum_threshold"
"below_minimum_threshold"
elsif content.dig("vat_info", "reversed")
"eu_vat_reversed"
else
country = ISO3166::Country.new(content.dig("billing_info", "country"))
if country.alpha2 == "NL"
"nl"
elsif country.in_eu_vat?
"eu"
else
"non_eu"
end
end
"#{begin_time.strftime("%Y/%m")}/#{group}/#{filename}"
end
def after_destroy
super
begin
Invoice.blob_storage_client.delete_object(bucket: Config.invoices_bucket_name, key: blob_key)
rescue Aws::S3::Errors::NoSuchKey
end
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.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)
data = Serializers::Invoice.serialize(self)
pdf = generate_pdf(data)
unless data.billing_email
Clog.emit("Couldn't send the invoice because it has no billing information") { {invoice_no_billing_info: {ubid:}} }
return
end
persist(pdf)
messages = if below_threshold
["Since the invoice total of #{data.total} is below our minimum charge threshold, there will be no charges for this month."]
else
["The invoice amount of #{data.total} will be debited from your credit card on file."]
end
github_usage = data.items.select { it.description.include?("GitHub Runner") }.sum(&: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(data.billing_email, "Ubicloud #{data.name} Invoice ##{data.invoice_number}",
greeting: "Dear #{data.billing_name},",
body: ["Please find your current invoice ##{data.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#{data.path}",
attachments: [[filename, pdf]])
end
def send_failure_email(errors)
data = Serializers::Invoice.serialize(self)
receivers = [data.billing_email]
receivers += project.accounts.select { Authorization.has_permission?(project, it, "Project:billing", project) }.map(&:email)
Util.send_email(receivers.uniq, "Urgent: Action Required to Prevent Service Disruption",
cc: Config.mail_from,
greeting: "Dear #{data.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 { "- #{it}" },
"The invoice amount of #{data.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 = Serializers::Invoice.serialize(self))
pdf = Prawn::Document.new(
page_size: "A4",
page_layout: :portrait,
info: {Title: filename, Creator: "Ubicloud", CreationDate: 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
pdf.text data.issuer_name, style: :semibold, color: dark_gray if data.issuer_name
pdf.text "#{data.issuer_address},"
pdf.text "#{data.issuer_city}, #{data.issuer_state} #{data.issuer_postal_code},"
pdf.text data.issuer_country
pdf.text "#{data.issuer_in_eu_vat ? "VAT" : "Tax"} ID: #{data.issuer_tax_id}" if data.issuer_tax_id
pdf.text "CCI/KVK ID: #{data.issuer_trade_id}" if data.issuer_trade_id
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
if data.billing_name
pdf.text "Bill to:", style: :semibold, color: dark_gray, size: 14
pdf.text data.company_name.to_s.strip.empty? ? data.billing_name : data.company_name, style: :semibold, color: dark_gray, size: 14
pdf.move_down 5
pdf.text "#{data.billing_address},"
pdf.text "#{data.billing_city}, #{data.billing_state} #{data.billing_postal_code},"
pdf.text data.billing_country
pdf.text "#{data.billing_in_eu_vat ? "VAT" : "Tax"} ID: #{data.tax_id}" if data.tax_id
end
end
# Row 2, Right Column: Invoice dates and note
pdf.bounding_box([right_column_x, row_y], width: column_width) do
right_data = [["Invoice date:", data.date], ["Due date:", data.date]]
right_data << ["Note:", data.note] if data.note
pdf.table(right_data, position: :right) do
style(row(0..2).columns(0..2), padding: [2, 5, 2, 5], borders: [])
style(column(0), align: :right, width: 90, text_wrap: :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 { [it.name, it.description, it.usage, it.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,
(data.free_inference_tokens_credit != "$0.00") ? ["Free Inference Tokens:", "-#{data.free_inference_tokens_credit}"] : nil,
# :nocov:
if data.vat_amount != "$0.00"
["VAT (#{data.vat_rate}%):", "(#{data.vat_amount_eur}) #{data.vat_amount}"]
end,
(data.total != "$0.00" && data.vat_reversed) ? [{content: "VAT subject to reverse charge", colspan: 2}] : nil,
["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
def persist(pdf)
Invoice.blob_storage_client.put_object(
bucket: Config.invoices_bucket_name,
key: blob_key,
body: pdf,
content_type: "application/pdf",
if_none_match: "*"
)
end
def self.blob_storage_client
Aws::S3::Client.new(
endpoint: Config.invoices_blob_storage_endpoint,
access_key_id: Config.invoices_blob_storage_access_key,
secret_access_key: Config.invoices_blob_storage_secret_key,
region: "auto",
request_checksum_calculation: "when_required",
response_checksum_validation: "when_required"
)
end
end
# 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)