ubicloud/routes/project/billing.rb
Enes Cakir 7c223f16dc Set nil when billing info attributes are empty
tax_id, company_name, postal_code, state, and city are not required on
the billing info update form. When they are not provided, we should set
them to nil instead of an empty string.

Since an empty string is truthy in Ruby, it causes issues when we try to
check whether billing info details are present.
2025-09-17 21:46:20 +03:00

189 lines
7.4 KiB
Ruby

# frozen_string_literal: true
require "stripe"
require "countries"
class Clover
hash_branch(:project_prefix, "billing") do |r|
r.web do
unless (Stripe.api_key = Config.stripe_secret_key)
response.status = 501
response.content_type = :text
next "Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing."
end
authorize("Project:billing", @project)
r.get true do
view "project/billing"
end
r.post true do
if (billing_info = @project.billing_info)
handle_validation_failure("project/billing")
current_tax_id = billing_info.stripe_data["tax_id"]
tp = typecast_params
new_tax_id = tp.nonempty_str("tax_id")&.gsub(/[^a-zA-Z0-9]/, "")
begin
Stripe::Customer.update(billing_info.stripe_id, {
name: tp.str!("name"),
email: tp.str!("email").strip,
address: {
country: tp.str!("country"),
state: tp.nonempty_str("state"),
city: tp.nonempty_str("city"),
postal_code: tp.nonempty_str("postal_code"),
line1: tp.str!("address"),
line2: nil
},
metadata: {
tax_id: new_tax_id,
company_name: tp.nonempty_str("company_name"),
note: tp.nonempty_str("note")
}
})
if new_tax_id != current_tax_id
DB.transaction do
billing_info.update(valid_vat: nil)
if new_tax_id && billing_info.country&.in_eu_vat?
Strand.create(prog: "ValidateVat", label: "start", stack: [{subject_id: billing_info.id}])
end
end
end
audit_log(@project, "update_billing")
rescue Stripe::InvalidRequestError => e
raise_web_error(e.message)
end
flash["notice"] = "Billing info updated"
r.redirect billing_path
else
no_audit_log
end
checkout = Stripe::Checkout::Session.create(
payment_method_types: ["card"],
mode: "setup",
customer_creation: "always",
billing_address_collection: "required",
success_url: "#{Config.base_url}#{@project.path}/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "#{Config.base_url}#{@project.path}/billing"
)
r.redirect checkout.url, 303
end
r.get "success" do
handle_validation_failure("project/billing")
checkout_session = Stripe::Checkout::Session.retrieve(typecast_params.str!("session_id"))
setup_intent = Stripe::SetupIntent.retrieve(checkout_session["setup_intent"])
stripe_id = setup_intent["payment_method"]
stripe_payment_method = Stripe::PaymentMethod.retrieve(stripe_id)
card_fingerprint = stripe_payment_method["card"]["fingerprint"]
unless PaymentMethod.where(fraud: true, card_fingerprint:).empty?
raise_web_error("Payment method you added is labeled as fraud. Please contact support.")
end
# Pre-authorize card to check if it is valid, if so
# authorization won't be captured and will be refunded immediately
begin
customer_stripe_id = setup_intent["customer"]
# Pre-authorizing random amount to verify card. As it is
# commonly done with other companies, apparently it is
# better to detect fraud then pre-authorizing fixed amount.
# That money will be kept until next billing period and if
# it's not a fraud, it will be applied to the invoice.
preauth_amount = [100, 200, 300, 400, 500].sample
payment_intent = Stripe::PaymentIntent.create({
amount: preauth_amount,
currency: "usd",
confirm: true,
off_session: true,
capture_method: "manual",
customer: customer_stripe_id,
payment_method: stripe_id
})
if payment_intent.status != "requires_capture"
raise "Authorization failed"
end
rescue
# Log and redirect if Stripe card error or our manual raise
Clog.emit("Couldn't pre-authorize card") { {card_authorization: {project_id: @project.id, customer_stripe_id: customer_stripe_id}} }
raise_web_error("We couldn't pre-authorize your card for verification. Please make sure it can be pre-authorized up to $5 or contact our support team at support@ubicloud.com.")
end
DB.transaction do
unless (billing_info = @project.billing_info)
billing_info = BillingInfo.create(stripe_id: customer_stripe_id)
@project.update(billing_info_id: billing_info.id)
end
PaymentMethod.create(billing_info_id: billing_info.id, stripe_id: stripe_id, card_fingerprint: card_fingerprint, preauth_intent_id: payment_intent.id, preauth_amount: preauth_amount)
end
unless @project.billing_info.has_address?
Stripe::Customer.update(@project.billing_info.stripe_id, {
address: stripe_payment_method["billing_details"]["address"].to_hash
})
end
flash["notice"] = "Payment method added successfully. $#{preauth_amount / 100} is authorized on your card for verification purposes. It's canceled already and depending on your bank, it may take up to two weeks to refund the money."
r.redirect billing_path
end
r.on "payment-method" do
r.get "create" do
next unless (billing_info = @project.billing_info)
checkout = Stripe::Checkout::Session.create(
payment_method_types: ["card"],
mode: "setup",
customer: billing_info.stripe_id,
billing_address_collection: billing_info.has_address? ? "auto" : "required",
success_url: "#{Config.base_url}#{@project.path}/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "#{Config.base_url}#{@project.path}/billing"
)
r.redirect checkout.url, 303
end
r.delete :ubid_uuid do |id|
next unless (payment_method = PaymentMethod[id:, billing_info_id: @project.billing_info_id])
unless payment_method.billing_info.payment_methods_dataset.count > 1
response.status = 400
next {error: {message: "You can't delete the last payment method of a project."}}
end
DB.transaction do
payment_method.destroy
audit_log(payment_method, "destroy")
end
204
end
end
r.get "invoice", ["current", :ubid_uuid] do |id|
next unless (invoice = (id == "current") ? @project.current_invoice : Invoice[id:, project_id: @project.id])
if invoice.status == "current"
@invoice_data = Serializers::Invoice.serialize(invoice)
view "project/invoice"
else
response.content_type = :pdf
response["content-disposition"] = "inline; filename=\"#{invoice.filename}\""
begin
Invoice.blob_storage_client.get_object(bucket: Config.invoices_bucket_name, key: invoice.blob_key).body.read
rescue Aws::S3::Errors::NoSuchKey
Clog.emit("Could not find the invoice") { {not_found_invoice: {invoice_ubid: invoice.ubid}} }
invoice.generate_pdf
end
end
end
end
end
end