Skip to content

Commit 15c648e

Browse files
feat(dunning): Add BCC emails addresses (#3354)
## Description When creating a dunning campaign, you can now add a list of email addresses to pass in the `bcc` field. This can help accounting team keeping track of payment requests. In GraphQL, the field is an array of string, each string is a valid email address. It's NOT a single string containing comma-separated email addresses (like we might do in other places). ```rb ["[email protected]", "[email protected]"] 🆚 "[email protected],[email protected]" ``` The dunning campaign is attached to the payment request so I only had to check it in the Mailer. The rest is basically model + gql boilerplate.
1 parent 1715bd4 commit 15c648e

21 files changed

+185
-18
lines changed

app/graphql/types/dunning_campaigns/create_input.rb

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class CreateInput < Types::BaseInputObject
66
graphql_name "CreateDunningCampaignInput"
77

88
argument :applied_to_organization, Boolean, required: true
9+
argument :bcc_emails, [String], required: false
910
argument :code, String, required: true
1011
argument :days_between_attempts, Integer, required: true
1112
argument :max_attempts, Integer, required: true

app/graphql/types/dunning_campaigns/object.rb

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Object < Types::BaseObject
88
field :id, ID, null: false
99

1010
field :applied_to_organization, Boolean, null: false
11+
field :bcc_emails, [String], null: true
1112
field :code, String, null: false
1213
field :customers_count, Integer, null: false
1314
field :days_between_attempts, Integer, null: false

app/graphql/types/dunning_campaigns/update_input.rb

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class UpdateInput < Types::BaseInputObject
88
argument :id, ID, required: true
99

1010
argument :applied_to_organization, Boolean, required: false
11+
argument :bcc_emails, [String], required: false
1112
argument :code, String, required: false
1213
argument :days_between_attempts, Integer, required: false
1314
argument :description, String, required: false

app/mailers/payment_request_mailer.rb

+3
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ def requested
1515
@invoices = @payment_request.invoices
1616
@payment_url = ::PaymentRequests::Payments::GeneratePaymentUrlService.call(payable: @payment_request).payment_url
1717

18+
bcc_emails = @payment_request.dunning_campaign&.bcc_emails
19+
1820
I18n.with_locale(@customer.preferred_document_locale) do
1921
mail(
2022
to: @payment_request.email,
2123
from: email_address_with_name(@organization.from_email_address, @organization.name),
24+
bcc: bcc_emails,
2225
reply_to: email_address_with_name(@organization.email, @organization.name),
2326
subject: I18n.t(
2427
"email.payment_request.requested.subject",

app/models/dunning_campaign.rb

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class DunningCampaign < ApplicationRecord
1616
accepts_nested_attributes_for :thresholds
1717

1818
validates :name, presence: true
19+
validates :bcc_emails, email_array: true
1920
validates :days_between_attempts, numericality: {greater_than: 0}
2021
validates :max_attempts, numericality: {greater_than: 0}
2122
validates :code,
@@ -52,6 +53,7 @@ def reset_customers_last_attempt
5253
#
5354
# id :uuid not null, primary key
5455
# applied_to_organization :boolean default(FALSE), not null
56+
# bcc_emails :string default([]), is an Array
5557
# code :string not null
5658
# days_between_attempts :integer default(1), not null
5759
# deleted_at :datetime

app/services/dunning_campaigns/create_service.rb

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def call
2424
dunning_campaign = organization.dunning_campaigns.create!(
2525
applied_to_organization: params[:applied_to_organization],
2626
code: params[:code],
27+
bcc_emails: Array.wrap(params[:bcc_emails]),
2728
days_between_attempts: params[:days_between_attempts],
2829
max_attempts: params[:max_attempts],
2930
name: params[:name],

app/services/dunning_campaigns/update_service.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def call
3333
attr_reader :dunning_campaign, :organization, :params
3434

3535
def permitted_attributes
36-
params.slice(:name, :code, :description, :days_between_attempts, :max_attempts)
36+
params.slice(:name, :bcc_emails, :code, :description, :days_between_attempts, :max_attempts)
3737
end
3838

3939
def handle_thresholds
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
class EmailArrayValidator < ActiveModel::EachValidator
4+
def validate_each(record, attribute, value)
5+
unless value.is_a? Array
6+
record.errors.add(attribute, "must_be_an_array")
7+
return
8+
end
9+
10+
value.each_with_index do |email, index|
11+
unless valid? email
12+
record.errors.add(attribute, "invalid_email_format[#{index},#{email}]")
13+
end
14+
end
15+
end
16+
17+
protected
18+
19+
def valid?(value)
20+
value&.match(Regex::EMAIL)
21+
end
22+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class AddEmailBccToDunningCampaign < ActiveRecord::Migration[7.2]
4+
def change
5+
add_column :dunning_campaigns, :bcc_emails, :string, array: true, default: []
6+
end
7+
end

db/schema.rb

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

schema.graphql

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

schema.json

+60
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/graphql/mutations/dunning_campaigns/create_spec.rb

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
name: "Dunning campaign name",
1212
code: "dunning-campaign-code",
1313
description: "Dunning campaign description",
14+
bccEmails: ["[email protected]"],
1415
maxAttempts: 3,
1516
daysBetweenAttempts: 1,
1617
appliedToOrganization: false,
@@ -31,6 +32,7 @@
3132
name
3233
code
3334
description
35+
bccEmails
3436
maxAttempts
3537
daysBetweenAttempts
3638
appliedToOrganization
@@ -63,6 +65,7 @@
6365
"name" => "Dunning campaign name",
6466
"code" => "dunning-campaign-code",
6567
"description" => "Dunning campaign description",
68+
"bccEmails" => ["[email protected]"],
6669
"maxAttempts" => 3,
6770
"daysBetweenAttempts" => 1,
6871
"appliedToOrganization" => false,

spec/graphql/resolvers/dunning_campaign_resolver_spec.rb

+3-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
id
2222
customersCount
2323
appliedToOrganization
24+
bccEmails
2425
code
2526
daysBetweenAttempts
2627
description
@@ -37,7 +38,7 @@
3738

3839
let(:membership) { create(:membership) }
3940
let(:organization) { membership.organization }
40-
let(:dunning_campaign) { create(:dunning_campaign, organization:) }
41+
let(:dunning_campaign) { create(:dunning_campaign, organization:, bcc_emails: %w[[email protected]]) }
4142
let(:dunning_campaign_threshold) { create(:dunning_campaign_threshold, dunning_campaign:) }
4243

4344
before do
@@ -57,6 +58,7 @@
5758
"id" => dunning_campaign.id,
5859
"customersCount" => 0,
5960
"appliedToOrganization" => dunning_campaign.applied_to_organization,
61+
"bccEmails" => dunning_campaign.bcc_emails,
6062
"code" => dunning_campaign.code,
6163
"daysBetweenAttempts" => dunning_campaign.days_between_attempts,
6264
"description" => dunning_campaign.description,

spec/graphql/types/dunning_campaigns/create_input_spec.rb

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
it do
99
expect(subject).to accept_argument(:applied_to_organization).of_type("Boolean!")
10+
expect(subject).to accept_argument(:bcc_emails).of_type("[String!]")
1011
expect(subject).to accept_argument(:code).of_type("String!")
1112
expect(subject).to accept_argument(:days_between_attempts).of_type("Int!")
1213
expect(subject).to accept_argument(:max_attempts).of_type("Int!")

spec/graphql/types/dunning_campaigns/object_spec.rb

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
expect(subject).to have_field(:customers_count).of_type("Int!")
1212
expect(subject).to have_field(:applied_to_organization).of_type("Boolean!")
13+
expect(subject).to have_field(:bcc_emails).of_type("[String!]")
1314
expect(subject).to have_field(:code).of_type("String!")
1415
expect(subject).to have_field(:days_between_attempts).of_type("Int!")
1516
expect(subject).to have_field(:max_attempts).of_type("Int!")

spec/graphql/types/dunning_campaigns/update_input_spec.rb

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
expect(subject).to accept_argument(:id).of_type("ID!")
1010

1111
expect(subject).to accept_argument(:applied_to_organization).of_type("Boolean")
12+
expect(subject).to accept_argument(:bcc_emails).of_type("[String!]")
1213
expect(subject).to accept_argument(:code).of_type("String")
1314
expect(subject).to accept_argument(:days_between_attempts).of_type("Int")
1415
expect(subject).to accept_argument(:description).of_type("String")

spec/mailers/payment_request_mailer_spec.rb

+15-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
require "rails_helper"
44

55
RSpec.describe PaymentRequestMailer, type: :mailer do
6-
subject(:payment_request_mailer) { described_class }
6+
subject(:mailer) { described_class.with(payment_request:).requested }
77

88
let(:organization) { create(:organization, document_number_prefix: "ORG-123B") }
99
let(:first_invoice) { create(:invoice, total_amount_cents: 1000, total_paid_amount_cents: 1, organization:) }
@@ -38,18 +38,16 @@
3838
end
3939

4040
specify do
41-
mailer = payment_request_mailer.with(payment_request:).requested
42-
4341
expect(mailer.to).to eq([payment_request.email])
4442
expect(mailer.reply_to).to eq([payment_request.organization.email])
43+
expect(mailer.bcc).to be_nil
4544
expect(mailer.body.encoded).to include(CGI.escapeHTML(first_invoice.number))
4645
expect(mailer.body.encoded).to include(CGI.escapeHTML(second_invoice.number))
4746
expect(mailer.body.encoded).to include(CGI.escapeHTML(MoneyHelper.format(first_invoice.total_due_amount)))
4847
expect(mailer.body.encoded).to include(CGI.escapeHTML(MoneyHelper.format(second_invoice.total_due_amount)))
4948
end
5049

5150
it "calls the generate payment url service" do
52-
mailer = payment_request_mailer.with(payment_request:).requested
5351
parsed_body = Nokogiri::HTML(mailer.body.encoded)
5452

5553
expect(parsed_body.at_css("a#payment_link")["href"]).to eq(payment_url)
@@ -59,11 +57,23 @@
5957
.with(payable: payment_request)
6058
end
6159

60+
context "when payment request has dunning campaign attached and there are 2 addresses in bcc_emails" do
61+
let(:bcc_emails) { %w[[email protected] [email protected]] }
62+
let(:dunning_campaign) { create(:dunning_campaign, organization:, bcc_emails:) }
63+
64+
before do
65+
payment_request.update(dunning_campaign:)
66+
end
67+
68+
it "includes the BCC email addresses in the mailer" do
69+
expect(mailer.bcc).to match_array(bcc_emails)
70+
end
71+
end
72+
6273
context "when payment request email is nil" do
6374
before { payment_request.update(email: nil) }
6475

6576
it "returns a mailer with nil values" do
66-
mailer = payment_request_mailer.with(payment_request:).requested
6777
expect(mailer.to).to be_nil
6878
end
6979
end
@@ -72,7 +82,6 @@
7282
before { organization.update(email: nil) }
7383

7484
it "returns a mailer with nil values" do
75-
mailer = payment_request_mailer.with(payment_request:).requested
7685
expect(mailer.to).to be_nil
7786
end
7887
end
@@ -85,7 +94,6 @@
8594
end
8695

8796
it "does not include the payment link" do
88-
mailer = payment_request_mailer.with(payment_request:).requested
8997
parsed_body = Nokogiri::HTML(mailer.body.encoded)
9098

9199
expect(parsed_body.css("a#payment_link")).not_to be_present

0 commit comments

Comments
 (0)