The payment method has an `order` column. I intended to add this feature initially to allow customers to prioritize their payment methods, so they can select a primary and a backup option. However, we haven't added it to the UI yet, so all `order` columns are currently nil. When a payment fails, customers simply log in to the console and add a new payment method while keeping the old one. When we try to charge the invoice, we start with the oldest method, which probably isn't working anymore. We should charge from the newest method to the oldest to reduce our payment failure rate.
159 lines
9.2 KiB
Ruby
159 lines
9.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "spec_helper"
|
|
|
|
RSpec.describe Invoice do
|
|
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") }
|
|
|
|
before do
|
|
allow(invoice).to receive(:reload)
|
|
allow(invoice).to receive(:project).and_return(instance_double(Project, path: "/project/p1", accounts: []))
|
|
allow(Config).to receive(:stripe_secret_key).and_return("secret_key")
|
|
end
|
|
|
|
describe ".send_failure_email" do
|
|
it "sends failure email to accounts with billing permissions in addition to the provided billing email" do
|
|
project = Project.create_with_id(name: "cool-project")
|
|
accounts = (0..2).map { Account.create_with_id(email: "account#{_1}@example.com").tap { |a| a.add_project(project) } }
|
|
AccessControlEntry.create_with_id(project_id: project.id, subject_id: accounts[0].id)
|
|
AccessControlEntry.create_with_id(project_id: project.id, subject_id: accounts[1].id, action_id: ActionType::NAME_MAP["Vm:view"])
|
|
AccessControlEntry.create_with_id(project_id: project.id, subject_id: accounts[2].id, action_id: ActionType::NAME_MAP["Project:billing"])
|
|
|
|
invoice = described_class.create_with_id(project_id: project.id, invoice_number: "001", begin_time: Time.now, end_time: Time.now, content: {
|
|
"billing_info" => {"email" => "billing@example.com"},
|
|
"resources" => [],
|
|
"subtotal" => 0.0,
|
|
"credit" => 0.0,
|
|
"discount" => 0.0,
|
|
"cost" => 0.0
|
|
})
|
|
|
|
invoice.send_failure_email([])
|
|
|
|
expect(Mail::TestMailer.deliveries.first.to).to contain_exactly("billing@example.com", accounts[0].email, accounts[2].email)
|
|
end
|
|
end
|
|
|
|
describe ".send_success_email" do
|
|
it "does not send the invoice if it has no billing information" do
|
|
project = Project.create_with_id(name: "cool-project")
|
|
invoice = described_class.create_with_id(project_id: project.id, invoice_number: "001", begin_time: Time.now, end_time: Time.now, content: {
|
|
"resources" => [],
|
|
"subtotal" => 0.0,
|
|
"credit" => 0.0,
|
|
"discount" => 0.0,
|
|
"cost" => 0.0
|
|
})
|
|
expect(Clog).to receive(:emit).with("Couldn't send the invoice because it has no billing information").and_call_original
|
|
invoice.send_success_email
|
|
expect(Mail::TestMailer.deliveries.length).to eq 0
|
|
end
|
|
end
|
|
|
|
describe ".charge" do
|
|
it "not charge if Stripe not enabled" do
|
|
allow(Config).to receive(:stripe_secret_key).and_return(nil)
|
|
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
|
|
expect(Clog).to receive(:emit).with("Invoice already charged.").and_call_original
|
|
invoice.status = "paid"
|
|
expect(invoice.charge).to be true
|
|
end
|
|
|
|
it "not charge if less than minimum charge threshold" do
|
|
invoice.content["billing_info"] = {"id" => billing_info.id, "email" => "customer@example.com"}
|
|
invoice.content["cost"] = 0.4
|
|
expect(invoice).to receive(:update).with(status: "below_minimum_threshold")
|
|
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
|
|
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
|
|
invoice.content["billing_info"] = {"id" => billing_info.id}
|
|
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 all payment methods fails" do
|
|
invoice.content["billing_info"] = {"id" => billing_info.id, "email" => "foo@example.com"}
|
|
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).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(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 "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" => "customer@example.com"}
|
|
invoice.content["resources"] = [{"cost" => 4.3384, "line_items" => [{"cost" => 4.3384, "amount" => 5423.0, "duration" => 1, "location" => "global", "description" => "standard-2 GitHub Runner", "resource_type" => "GitHubRunnerMinutes", "resource_family" => "standard-2"}], "resource_id" => "ed0b26bf-53c4-82d2-9a00-21e5f05dc364", "resource_name" => "Daily Usage 2024-02-26"}]
|
|
payment_method1 = PaymentMethod.create(billing_info_id: billing_info.id, stripe_id: "pm_1", created_at: Time.now + 20)
|
|
payment_method2 = PaymentMethod.create(billing_info_id: billing_info.id, stripe_id: "pm_2", created_at: Time.now + 10)
|
|
payment_method3 = PaymentMethod.create(billing_info_id: billing_info.id, stripe_id: "pm_3", created_at: Time.now)
|
|
# rubocop:disable RSpec/VerifiedDoubles
|
|
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(Clog).to receive(:emit).with("Invoice couldn't charged.").and_call_original
|
|
expect(Clog).to receive(:emit).with("Invoice charged.").and_call_original
|
|
project = instance_double(Project)
|
|
expect(project).to receive(:update).with(reputation: "verified")
|
|
expect(invoice).to receive(:project).and_return(project)
|
|
expect(invoice.charge).to be true
|
|
expect(invoice.status).to eq("paid")
|
|
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
|
|
expect(Mail::TestMailer.deliveries.first.attachments.length).to eq 1
|
|
end
|
|
|
|
it "does not update project reputation if cost is less than 5" do
|
|
invoice.content["cost"] = 4
|
|
invoice.content["billing_info"] = {"id" => billing_info.id}
|
|
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).and_return(double(Stripe::PaymentIntent, status: "succeeded", id: "pi_1234567890"))
|
|
# rubocop:enable RSpec/VerifiedDoubles
|
|
expect(invoice).to receive(:save).with(columns: [:status, :content])
|
|
expect(invoice).to receive(:send_success_email)
|
|
expect(invoice).not_to receive(:project)
|
|
expect(invoice.charge).to be true
|
|
end
|
|
end
|
|
end
|