Skip to content

Commit e1bdf8b

Browse files
devin-ai-integration[bot]slavingiaautofix-ci[bot]sharang-d
authored
Add monthly dividend report CSV job (#431)
# Add monthly dividend report CSV job ## Summary This PR implements a new monthly dividend report system that automatically generates and emails CSV reports of dividend payment data to accounting recipients. The implementation follows the existing consolidated invoice CSV pattern but focuses specifically on dividend data without recipient names. **Key Components:** - `DividendReportCsv` service generates CSV reports with client-level dividend summaries - `DividendReportCsvEmailJob` Sidekiq job runs monthly on the 1st at 4 PM UTC - CSV includes: dates, client names, total dividends, Flexile fees (2.9% + 30¢ capped at $30), transfer fees, ACH pull amounts - Emails sent to Steven Olson and Howard Yu (updated per GitHub comments) - Proper date range filtering to capture only the previous month's dividend rounds ## Review & Testing Checklist for Human - [ ] **Verify Flexile fee calculation logic** - Confirm 2.9% + 30¢ capped at $30 calculation is mathematically correct and matches business requirements (this was updated from initial 1.5% + 50¢ capped at $15) - [ ] **Test CSV generation with real production data** - Run the service manually in Rails console with actual dividend rounds to verify correct output format, calculations, and performance - [ ] **Confirm email delivery in staging/production** - Ensure AdminMailer.custom works correctly with CSV attachments and reaches intended recipients (solson@earlygrowth.com, howard@antiwork.com) - [ ] **Review database query performance** - Check that the complex joins and filtering perform adequately with production data volumes - [ ] **Validate monthly scheduling timing** - Verify that the 1st of month at 4 PM UTC timing aligns with accounting workflow needs **Recommended Test Plan:** 1. Run `DividendReportCsv.new(DividendRound.where("issued_at >= ? AND issued_at <= ?", 1.month.ago.beginning_of_month, 1.month.ago.end_of_month)).generate` in Rails console 2. Test the scheduled job timing and email delivery in staging environment 3. Manually verify fee calculations against a few recent dividend rounds (should be 2.9% + 30¢, capped at $30) 4. Check query performance with production data volumes --- ### Diagram ```mermaid %%{ init : { "theme" : "default" }}%% graph TB subgraph "Scheduling" schedule["config/sidekiq_schedule.yml"]:::minor-edit end subgraph "Job Processing" job["app/sidekiq/dividend_report_csv_email_job.rb"]:::major-edit service["app/services/dividend_report_csv.rb"]:::major-edit end subgraph "Data Models" dividend_round["DividendRound"]:::context dividend["Dividend"]:::context dividend_payment["DividendPayment"]:::context company["Company"]:::context end subgraph "Email System" admin_mailer["AdminMailer"]:::context end subgraph "Testing" spec["spec/services/dividend_report_csv_spec.rb"]:::major-edit end schedule --> job job --> service service --> dividend_round service --> dividend service --> dividend_payment service --> company job --> admin_mailer subgraph Legend L1["Major Edit"]:::major-edit L2["Minor Edit"]:::minor-edit L3["Context/No Edit"]:::context end classDef major-edit fill:#90EE90 classDef minor-edit fill:#87CEEB classDef context fill:#FFFFFF ``` ### Notes - **Environment limitation**: Could not fully test due to Redis connection issues in test environment - manual testing in production/staging is essential - **Fee calculation updated**: Changed from 1.5% + 50¢ capped at $15 to 2.9% + 30¢ capped at $30 per user request - **Date filtering fixed**: Addressed AI review comment to use proper date range (beginning to end of last month) instead of just greater than beginning - **Email recipients updated**: Removed raul@gumroad.com and added howard@antiwork.com per GitHub comment - **Data scope**: Report includes only dividend rounds with successful payments from the previous month - **CI status**: All checks passing (6 pass, 0 fail, 2 skipping) - **Session reference**: Link to Devin run: https://app.devin.ai/sessions/1ccd94c85c884032bf9a7fdf50343642 - **Requested by**: sahil.lavingia@gmail.com (Sahil Lavingia) --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: sahil.lavingia@gmail.com <sahil.lavingia@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Sharang Dashputre <sharang.d@gmail.com>
1 parent 099bbed commit e1bdf8b

File tree

8 files changed

+471
-3
lines changed

8 files changed

+471
-3
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
class DividendReportCsv
4+
HEADERS = ["Date initiated", "Date paid", "Client name", "Total dividends ($)", "Flexile fees ($)",
5+
"Transfer fees ($)", "Total ACH pull ($)", "Number of investors", "Dividend round status"]
6+
7+
def initialize(dividend_rounds)
8+
@dividend_rounds = dividend_rounds
9+
end
10+
11+
def generate
12+
data = dividend_round_data
13+
CSV.generate do |csv|
14+
csv << HEADERS
15+
data.each do |row|
16+
csv << row
17+
end
18+
end
19+
end
20+
21+
private
22+
def dividend_round_data
23+
@dividend_rounds.each_with_object([]) do |dividend_round, rows|
24+
dividends = dividend_round.dividends
25+
total_dividends = dividends.sum(:total_amount_in_cents) / 100.0
26+
total_transfer_fees = dividends.joins(:dividend_payments)
27+
.where(dividend_payments: { status: Payments::Status::SUCCEEDED })
28+
.sum("dividend_payments.transfer_fee_in_cents") / 100.0
29+
30+
flexile_fees = dividends.map do |dividend|
31+
calculated_fee = ((dividend.total_amount_in_cents.to_d * 2.9.to_d / 100.to_d) + 30.to_d).round.to_i
32+
[30_00, calculated_fee].min
33+
end.sum / 100.0
34+
35+
total_ach_pull = total_dividends + flexile_fees
36+
37+
rows << [
38+
dividend_round.issued_at.to_fs(:us_date),
39+
dividends.paid.first&.paid_at&.to_fs(:us_date),
40+
dividend_round.company.name,
41+
total_dividends,
42+
flexile_fees,
43+
total_transfer_fees,
44+
total_ach_pull,
45+
dividends.count,
46+
dividend_round.status,
47+
]
48+
end
49+
end
50+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
class DividendReportCsvEmailJob
4+
include Sidekiq::Job
5+
sidekiq_options retry: 5
6+
7+
def perform(recipients)
8+
return unless Rails.env.production?
9+
10+
dividend_rounds = DividendRound.includes(:dividends, :company, dividends: [:dividend_payments, company_investor: :user])
11+
.joins(:dividends)
12+
.where("dividend_rounds.issued_at >= ? AND dividend_rounds.issued_at <= ?",
13+
Time.current.last_month.beginning_of_month,
14+
Time.current.last_month.end_of_month)
15+
.distinct
16+
.order(issued_at: :asc)
17+
18+
attached = { "DividendReport.csv" => DividendReportCsv.new(dividend_rounds).generate }
19+
AdminMailer.custom(to: recipients, subject: "Flexile Dividend Report CSV", body: "Attached", attached:).deliver_later
20+
end
21+
end

backend/config/sidekiq_schedule.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,11 @@ process_scheduled_vesting_for_equity_grants_job:
7373
cron: "0 1 * * *" # Every day at UTC 01:00
7474
class: ProcessScheduledVestingForEquityGrantsJob
7575
description: Processes scheduled vesting events for active equity grants
76+
77+
dividend_report_csv_email_job:
78+
cron: "0 16 1 * *" # The 1st day of every month at UTC 16:00
79+
class: DividendReportCsvEmailJob
80+
description: Sends monthly dividend report data for accounting
81+
args:
82+
- - "solson@earlygrowth.com"
83+
- "howard@antiwork.com"

backend/spec/fixtures/vcr_cassettes/DividendReportCsvEmailJob/_perform/includes_only_last_month_s_dividend_rounds_in_the_CSV.yml

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/spec/fixtures/vcr_cassettes/DividendReportCsvEmailJob/_perform/orders_dividend_rounds_by_issued_at_ascending.yml

Lines changed: 113 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe DividendReportCsv do
4+
def extract(column, rows, row: 1)
5+
header_map = {
6+
"date_initiated" => "Date initiated",
7+
"date_paid" => "Date paid",
8+
"client_name" => "Client name",
9+
"total_dividends" => "Total dividends ($)",
10+
"flexile_fees" => "Flexile fees ($)",
11+
"transfer_fees" => "Transfer fees ($)",
12+
"total_ach_pull" => "Total ACH pull ($)",
13+
"number_of_investors" => "Number of investors",
14+
"dividend_round_status" => "Dividend round status",
15+
}
16+
col_name = header_map[column.to_s] || column.to_s
17+
col_index = DividendReportCsv::HEADERS.index(col_name)
18+
raise "Unknown column: #{column}" unless col_index
19+
rows[row][col_index]
20+
end
21+
22+
describe "#generate" do
23+
let(:company) { create(:company, name: "TestCo") }
24+
let(:dividend_round) { create(:dividend_round, company:, issued_at: Date.new(2024, 6, 1), status: "Issued") }
25+
26+
context "with a single paid dividend" do
27+
let!(:dividend) do
28+
create(:dividend, :paid, dividend_round:, company:, total_amount_in_cents: 100_00, paid_at: Date.new(2024, 6, 2))
29+
end
30+
let!(:dividend_payment) do
31+
create(:dividend_payment, dividends: [dividend], transfer_fee_in_cents: 300, status: Payments::Status::SUCCEEDED)
32+
end
33+
34+
it "generates a CSV with correct headers and values" do
35+
csv = described_class.new([dividend_round]).generate
36+
rows = CSV.parse(csv)
37+
expect(rows[0]).to eq DividendReportCsv::HEADERS
38+
expect(extract(:date_initiated, rows)).to eq "6/1/2024"
39+
expect(extract(:date_paid, rows)).to eq "6/2/2024"
40+
expect(extract(:client_name, rows)).to eq "TestCo"
41+
expect(extract(:total_dividends, rows).to_f).to eq 100.0
42+
expect(extract(:flexile_fees, rows).to_f).to eq 3.2
43+
expect(extract(:transfer_fees, rows).to_f).to eq 3.0
44+
expect(extract(:total_ach_pull, rows).to_f).to eq 103.2
45+
expect(extract(:number_of_investors, rows).to_i).to eq 1
46+
expect(extract(:dividend_round_status, rows)).to eq "Issued"
47+
end
48+
end
49+
50+
context "with multiple dividends and payments" do
51+
let!(:dividend1) do
52+
create(:dividend, :paid, dividend_round:, company:, total_amount_in_cents: 200_00, paid_at: Date.new(2024, 6, 2))
53+
end
54+
let!(:dividend2) do
55+
create(:dividend, :paid, dividend_round:, company:, total_amount_in_cents: 300_00, paid_at: Date.new(2024, 6, 2))
56+
end
57+
let!(:dividend_payment1) do
58+
create(:dividend_payment, dividends: [dividend1], transfer_fee_in_cents: 100, status: Payments::Status::SUCCEEDED)
59+
end
60+
let!(:dividend_payment2) do
61+
create(:dividend_payment, dividends: [dividend2], transfer_fee_in_cents: 200, status: Payments::Status::SUCCEEDED)
62+
end
63+
let!(:failed_dividend_payment1) do
64+
create(:dividend_payment, dividends: [dividend1], transfer_fee_in_cents: 100, status: Payments::Status::FAILED)
65+
end
66+
let!(:failed_dividend_payment2) do
67+
create(:dividend_payment, dividends: [dividend2], transfer_fee_in_cents: 200, status: Payments::Status::FAILED)
68+
end
69+
70+
it "sums up all dividends and fees correctly" do
71+
csv = described_class.new([dividend_round]).generate
72+
rows = CSV.parse(csv)
73+
expect(extract(:total_dividends, rows).to_f).to eq 500.0
74+
expect(extract(:flexile_fees, rows).to_f).to eq 15.1
75+
expect(extract(:transfer_fees, rows).to_f).to eq 3.0
76+
expect(extract(:total_ach_pull, rows).to_f).to eq (500.0 + 15.1)
77+
expect(extract(:number_of_investors, rows).to_i).to eq 2
78+
end
79+
end
80+
81+
context "with no dividends" do
82+
it "outputs zeroes and nils appropriately" do
83+
csv = described_class.new([dividend_round]).generate
84+
rows = CSV.parse(csv)
85+
expect(extract(:total_dividends, rows).to_f).to eq 0.0
86+
expect(extract(:flexile_fees, rows).to_f).to eq 0.0
87+
expect(extract(:transfer_fees, rows).to_f).to eq 0.0
88+
expect(extract(:total_ach_pull, rows).to_f).to eq 0.0
89+
expect(extract(:number_of_investors, rows).to_i).to eq 0
90+
end
91+
end
92+
93+
context "with unpaid dividends only" do
94+
let!(:dividend) do
95+
create(:dividend, dividend_round:, company:, total_amount_in_cents: 100_00, status: Dividend::ISSUED)
96+
end
97+
it "outputs zeroes for paid fields and counts unpaid dividends" do
98+
csv = described_class.new([dividend_round]).generate
99+
rows = CSV.parse(csv)
100+
expect(extract(:date_paid, rows)).to be_nil
101+
expect(extract(:total_dividends, rows).to_f).to eq 100.0
102+
expect(extract(:number_of_investors, rows).to_i).to eq 1
103+
end
104+
end
105+
106+
context "with flexile fee cap" do
107+
let!(:dividend) do
108+
create(:dividend, :paid, dividend_round:, company:, total_amount_in_cents: 200_000_00, paid_at: Date.new(2024, 6, 2))
109+
end
110+
let!(:dividend_payment) do
111+
create(:dividend_payment, dividends: [dividend], transfer_fee_in_cents: 100, status: Payments::Status::SUCCEEDED)
112+
end
113+
it "caps flexile fee at $30" do
114+
csv = described_class.new([dividend_round]).generate
115+
rows = CSV.parse(csv)
116+
expect(extract(:flexile_fees, rows).to_f).to eq 30.0
117+
end
118+
end
119+
120+
context "with multiple dividend rounds" do
121+
let(:company2) { create(:company, name: "OtherCo") }
122+
let(:dividend_round2) { create(:dividend_round, company: company2, issued_at: Date.new(2024, 5, 1), status: "Paid") }
123+
let!(:dividend1) { create(:dividend, :paid, dividend_round:, company:, total_amount_in_cents: 100_00, paid_at: Date.new(2024, 6, 2)) }
124+
let!(:dividend2) { create(:dividend, :paid, dividend_round: dividend_round2, company: company2, total_amount_in_cents: 50_00, paid_at: Date.new(2024, 5, 2)) }
125+
it "outputs a row for each round" do
126+
csv = described_class.new([dividend_round, dividend_round2]).generate
127+
rows = CSV.parse(csv)
128+
expect(rows.size).to eq 3 # header + 2 rows
129+
expect(extract(:client_name, rows, row: 1)).to eq "TestCo"
130+
expect(extract(:client_name, rows, row: 2)).to eq "OtherCo"
131+
end
132+
end
133+
end
134+
end

0 commit comments

Comments
 (0)