This is about the same amount of code than the previous approach, but has the following advantages: * Uses Roda's render plugin, so templates are cached and optimized into compiled methods. The previous approach created 5 separate Tilt::ErubiTemplate objects for every email rendered. The new approach does not create any Tilt::ErubiTemplate objects after the first email. * Uses part instead of render for simpler rendering with locals. * Moves EmailRenderer to separate file, so that reloading works correctly. * Skips the rendering of the email stylesheet, since it does not contain any ERB code. Instead, the file is included without rendering. * Uses fixed locals for the email templates, so providing an invalid local will result in an error. * Removes unnecessary empty `<style>` tag in email layout. The only spec change is adding an email to one of the invoice specs. I'm not sure why this didn't fail before, but the mail library complains if it tries to deliver a email with no recipients.
143 lines
8.5 KiB
Ruby
143 lines
8.5 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 ".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_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).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
|