Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send email when the invoice processed #1534

Merged
merged 4 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,13 @@ def self.safe_write_to_file(filename, content)
end
end

def self.send_email(receiver, subject, greeting: nil, body: nil, button_title: nil, button_link: nil)
def self.send_email(receiver, subject, greeting: nil, body: nil, button_title: nil, button_link: nil, cc: nil)
html = EmailRenderer.new.render "email/layout", locals: {subject: subject, greeting: greeting, body: body, button_title: button_title, button_link: button_link}
Mail.deliver do
from Config.mail_from
to receiver
subject subject
cc cc

text_part do
body "#{greeting}\n#{Array(body).join("\n")}\n#{button_link}"
Expand Down
116 changes: 80 additions & 36 deletions model/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
require "stripe"

class Invoice < Sequel::Model
many_to_one :project

include ResourceMethods

def path
Expand All @@ -15,58 +17,100 @@ def name
end

def charge
reload # Reload to get the latest status to avoid double charging
unless (Stripe.api_key = Config.stripe_secret_key)
puts "Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing."
return
Clog.emit("Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing.")
return true
end

if status != "unpaid"
puts "Invoice[#{ubid}] already charged: #{status}"
return
Clog.emit("Invoice already charged.") { {invoice_already_charged: {ubid: ubid, status: status}} }
return true
end

if content["cost"] < Config.minimum_invoice_charge_threshold
amount = content["cost"].to_f.round(2)
if amount < Config.minimum_invoice_charge_threshold
update(status: "below_minimum_threshold")
puts "Invoice[#{ubid}] cost is less than minimum charge cost: $#{content["cost"]}"
return
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

unless (billing_info = BillingInfo[content.dig("billing_info", "id")])
puts "Invoice[#{ubid}] doesn't have billing info"
return
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

payment_methods = billing_info.payment_methods_dataset.order(:order).all

payment_methods.each do |payment_method|
amount = content["cost"].to_f.round(2)
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: payment_method.stripe_id
})

if payment_intent.status == "succeeded"
puts "Invoice[#{ubid}] charged with PaymentMethod[#{payment_method.ubid}] for $#{amount}"
self.status = "paid"
content.merge!({
"payment_method" => {
"id" => payment_method.id,
"stripe_id" => payment_method.stripe_id
},
"payment_intent" => payment_intent.id
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
})
save(columns: [:status, :content])
return payment_intent.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
enescakir marked this conversation as resolved.
Show resolved Hide resolved
next
end

puts "Invoice[#{ubid}] couldn't charge with PaymentMethod[#{payment_method.ubid}]: #{payment_intent.status}"
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])
send_success_email
return true
end

puts "Invoice[#{ubid}] couldn't charge with any payment method"
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::Web::Invoice.new(:detailed).serialize(self)
message = 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
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]} at the link.",
byucesoy marked this conversation as resolved.
Show resolved Hide resolved
message,
"If you have any questions, please send us a support request via [email protected], and include your invoice number."],
button_title: "View Invoice",
button_link: "#{Config.base_url}#{project.path}/billing#{ser[:path]}")
end

def send_failure_email(errors)
ser = Serializers::Web::Invoice.new(:detailed).serialize(self)
Util.send_email(ser[:billing_email], "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 [email protected]."],
button_title: "Update Payment Method",
button_link: "#{Config.base_url}#{project.path}/billing")
end
end

Expand Down
3 changes: 3 additions & 0 deletions serializers/web/invoice.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "countries"
byucesoy marked this conversation as resolved.
Show resolved Hide resolved

class Serializers::Web::Invoice < Serializers::Base
def self.base(inv)
{
Expand Down Expand Up @@ -31,6 +33,7 @@ def self.humanized_cost(cost)
base(inv).merge(
{
billing_name: inv.content.dig("billing_info", "name"),
billing_email: inv.content.dig("billing_info", "email"),
billing_address: inv.content.dig("billing_info", "address"),
billing_country: ISO3166::Country.new(inv.content.dig("billing_info", "country"))&.common_name,
billing_city: inv.content.dig("billing_info", "city"),
Expand Down
96 changes: 57 additions & 39 deletions spec/model/invoice_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,81 +3,99 @@
require_relative "spec_helper"

RSpec.describe Invoice do
subject(:invoice) { described_class.new(id: "50d5aae4-311c-843b-b500-77fbc7778050", content: {"cost" => 10}, status: "unpaid") }
subject(:invoice) { described_class.new(id: "50d5aae4-311c-843b-b500-77fbc7778050", begin_time: Time.now, end_time: Time.now, created_at: Time.now, content: {"cost" => 10, "subtotal" => 11, "credit" => 1, "discount" => 0, "resources" => []}, status: "unpaid") }

let(:billing_info) { BillingInfo.create_with_id(stripe_id: "cs_1234567890") }
let(:payment_method) { PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_1234567890") }

before do
allow(invoice).to receive(:reload)
allow(invoice).to receive(:project).and_return(instance_double(Project, path: "/project/p1"))
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
end

describe ".charge" do
it "not charge if Stripe not enabled" do
allow(Config).to receive(:stripe_secret_key).and_return(nil)
expect do
expect(invoice.charge).to be_nil
end.to output("Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing.\n").to_stdout
expect(Clog).to receive(:emit).with("Billing is not enabled. Set STRIPE_SECRET_KEY to enable billing.").and_call_original
expect(invoice.charge).to be true
end

it "not charge if already charged" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
expect(Clog).to receive(:emit).with("Invoice already charged.").and_call_original
invoice.status = "paid"
expect do
expect(invoice.charge).to be_nil
end.to output("Invoice[1va3atns1h3j3pm07fyy7ey050] already charged: paid\n").to_stdout
expect(invoice.charge).to be true
end

it "not charge if less than minimum charge threshold" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
invoice.content["billing_info"] = {"id" => billing_info.id, "email" => "[email protected]"}
invoice.content["cost"] = 0.4
expect(invoice).to receive(:update).with(status: "below_minimum_threshold")
expect do
expect(invoice.charge).to be_nil
end.to output("Invoice[1va3atns1h3j3pm07fyy7ey050] cost is less than minimum charge cost: $0.4\n").to_stdout
expect(Clog).to receive(:emit).with("Invoice cost is less than minimum charge cost.").and_call_original
expect(invoice.charge).to be true
expect(Mail::TestMailer.deliveries.length).to eq 1
end

it "not charge if doesn't have billing info" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
expect do
expect(invoice.charge).to be_nil
end.to output("Invoice[1va3atns1h3j3pm07fyy7ey050] doesn't have billing info\n").to_stdout
expect(Clog).to receive(:emit).with("Invoice doesn't have billing info.").and_call_original
expect(invoice.charge).to be false
end

it "not charge if no payment methods" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
invoice.content["billing_info"] = {"id" => billing_info.id}
expect do
expect(invoice.charge).to be_nil
end.to output("Invoice[1va3atns1h3j3pm07fyy7ey050] couldn't charge with any payment method\n").to_stdout
expect(Clog).to receive(:emit).with("Invoice doesn't have billing info.").and_call_original
expect(invoice.charge).to be false
end

it "not charge if payment method fails" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
it "not charge if all payment methods fails" do
invoice.content["billing_info"] = {"id" => billing_info.id}
payment_method1 = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_1", order: 1)
payment_method2 = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_2", order: 2)

# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::PaymentIntent).to receive(:create).and_return(double(Stripe::PaymentIntent, status: "failed")).at_least(:once)
expect(Stripe::PaymentIntent).to receive(:create).with(hash_including(amount: 1000, customer: billing_info.stripe_id, payment_method: payment_method1.stripe_id))
.and_raise(Stripe::CardError.new("Unsufficient funds", {}))
expect(Stripe::PaymentIntent).to receive(:create).with(hash_including(amount: 1000, customer: billing_info.stripe_id, payment_method: payment_method2.stripe_id))
.and_raise(Stripe::CardError.new("Card declined", {}))
# rubocop:enable RSpec/VerifiedDoubles
expect do
expect(invoice.charge).to be_nil
end.to output("Invoice[1va3atns1h3j3pm07fyy7ey050] couldn't charge with PaymentMethod[#{payment_method.ubid}]: failed
Invoice[1va3atns1h3j3pm07fyy7ey050] couldn't charge with any payment method\n").to_stdout
expect(Clog).to receive(:emit).with("Invoice couldn't charged.").and_call_original.twice
expect(Clog).to receive(:emit).with("Invoice couldn't charged with any payment method.").and_call_original
expect(invoice.charge).to be false
expect(Mail::TestMailer.deliveries.length).to eq 1
end

it "can charge" do
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
it "fails if PaymentIntent does not raise an exception in case of failure" do
invoice.content["billing_info"] = {"id" => billing_info.id}
payment_method = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_1", order: 1)

# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::PaymentIntent).to receive(:create).with(hash_including(amount: 1000, customer: billing_info.stripe_id, payment_method: payment_method.stripe_id))
.and_return(double(Stripe::PaymentIntent, id: "payment-intent-id", status: "failed"))
# rubocop:enable RSpec/VerifiedDoubles
expect(Clog).to receive(:emit).with("BUG: payment intent should succeed here").and_call_original
expect(Clog).to receive(:emit).with("Invoice couldn't charged with any payment method.").and_call_original
expect(invoice.charge).to be false
end

it "can charge from a correct payment method even some of them are not working" do
invoice.content["billing_info"] = {"id" => billing_info.id, "email" => "[email protected]"}
payment_method1 = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_1", order: 1)
payment_method2 = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_2", order: 2)
payment_method3 = PaymentMethod.create_with_id(billing_info_id: billing_info.id, stripe_id: "pm_3", order: 3)
# rubocop:disable RSpec/VerifiedDoubles
expect(Stripe::PaymentIntent).to receive(:create).and_return(double(Stripe::PaymentIntent, status: "succeeded", id: "pi_1234567890")).with(hash_including(
amount: 1000,
customer: billing_info.stripe_id,
payment_method: payment_method.stripe_id
)).at_least(:once)
expect(Stripe::PaymentIntent).to receive(:create).with(hash_including(amount: 1000, customer: billing_info.stripe_id, payment_method: payment_method1.stripe_id))
.and_raise(Stripe::CardError.new("Declined", {}))
expect(Stripe::PaymentIntent).to receive(:create).with(hash_including(amount: 1000, customer: billing_info.stripe_id, payment_method: payment_method2.stripe_id))
.and_return(double(Stripe::PaymentIntent, status: "succeeded", id: "pi_1234567890"))
expect(Stripe::PaymentIntent).not_to receive(:create).with(hash_including(payment_method: payment_method3.stripe_id))
# rubocop:enable RSpec/VerifiedDoubles
expect(invoice).to receive(:save).with(columns: [:status, :content])
expect do
expect(invoice.charge).to eq("pi_1234567890")
end.to output("Invoice[1va3atns1h3j3pm07fyy7ey050] charged with PaymentMethod[#{payment_method.ubid}] for $10.0\n").to_stdout
expect(Clog).to receive(:emit).with("Invoice couldn't charged.").and_call_original
expect(Clog).to receive(:emit).with("Invoice charged.").and_call_original
expect(invoice.charge).to be true
expect(invoice.status).to eq("paid")
expect(invoice.content["payment_method"]["id"]).to eq(payment_method.id)
expect(invoice.content["payment_method"]["id"]).to eq(payment_method2.id)
expect(invoice.content["payment_intent"]).to eq("pi_1234567890")
expect(Mail::TestMailer.deliveries.length).to eq 1
end
end
end