Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit 0329a5f

Browse files
authored
Data exports (#2517)
* Import / export UI * Data exports * Lint fixes, brakeman update
1 parent b7c56e2 commit 0329a5f

20 files changed

+717
-2
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ gem "plaid"
7272
gem "rotp", "~> 6.3"
7373
gem "rqrcode", "~> 3.0"
7474
gem "activerecord-import"
75+
gem "rubyzip", "~> 2.3"
7576

7677
# State machines
7778
gem "aasm"

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,7 @@ DEPENDENCIES
672672
rubocop-rails-omakase
673673
ruby-lsp-rails
674674
ruby-openai
675+
rubyzip (~> 2.3)
675676
selenium-webdriver
676677
sentry-rails
677678
sentry-ruby
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
class FamilyExportsController < ApplicationController
2+
include StreamExtensions
3+
4+
before_action :require_admin
5+
before_action :set_export, only: [ :download ]
6+
7+
def new
8+
# Modal view for initiating export
9+
end
10+
11+
def create
12+
@export = Current.family.family_exports.create!
13+
FamilyDataExportJob.perform_later(@export)
14+
15+
respond_to do |format|
16+
format.html { redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly." }
17+
format.turbo_stream {
18+
stream_redirect_to settings_profile_path, notice: "Export started. You'll be able to download it shortly."
19+
}
20+
end
21+
end
22+
23+
def index
24+
@exports = Current.family.family_exports.ordered.limit(10)
25+
render layout: false # For turbo frame
26+
end
27+
28+
def download
29+
if @export.downloadable?
30+
redirect_to @export.export_file, allow_other_host: true
31+
else
32+
redirect_to settings_profile_path, alert: "Export not ready for download"
33+
end
34+
end
35+
36+
private
37+
38+
def set_export
39+
@export = Current.family.family_exports.find(params[:id])
40+
end
41+
42+
def require_admin
43+
unless Current.user.admin?
44+
redirect_to root_path, alert: "Access denied"
45+
end
46+
end
47+
end

app/jobs/family_data_export_job.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class FamilyDataExportJob < ApplicationJob
2+
queue_as :default
3+
4+
def perform(family_export)
5+
family_export.update!(status: :processing)
6+
7+
exporter = Family::DataExporter.new(family_export.family)
8+
zip_file = exporter.generate_export
9+
10+
family_export.export_file.attach(
11+
io: zip_file,
12+
filename: family_export.filename,
13+
content_type: "application/zip"
14+
)
15+
16+
family_export.update!(status: :completed)
17+
rescue => e
18+
Rails.logger.error "Family export failed: #{e.message}"
19+
Rails.logger.error e.backtrace.join("\n")
20+
family_export.update!(status: :failed)
21+
end
22+
end

app/models/family.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Family < ApplicationRecord
1818
has_many :invitations, dependent: :destroy
1919

2020
has_many :imports, dependent: :destroy
21+
has_many :family_exports, dependent: :destroy
2122

2223
has_many :entries, through: :accounts
2324
has_many :transactions, through: :accounts

app/models/family/data_exporter.rb

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
require "zip"
2+
require "csv"
3+
4+
class Family::DataExporter
5+
def initialize(family)
6+
@family = family
7+
end
8+
9+
def generate_export
10+
# Create a StringIO to hold the zip data in memory
11+
zip_data = Zip::OutputStream.write_buffer do |zipfile|
12+
# Add accounts.csv
13+
zipfile.put_next_entry("accounts.csv")
14+
zipfile.write generate_accounts_csv
15+
16+
# Add transactions.csv
17+
zipfile.put_next_entry("transactions.csv")
18+
zipfile.write generate_transactions_csv
19+
20+
# Add trades.csv
21+
zipfile.put_next_entry("trades.csv")
22+
zipfile.write generate_trades_csv
23+
24+
# Add categories.csv
25+
zipfile.put_next_entry("categories.csv")
26+
zipfile.write generate_categories_csv
27+
28+
# Add all.ndjson
29+
zipfile.put_next_entry("all.ndjson")
30+
zipfile.write generate_ndjson
31+
end
32+
33+
# Rewind and return the StringIO
34+
zip_data.rewind
35+
zip_data
36+
end
37+
38+
private
39+
40+
def generate_accounts_csv
41+
CSV.generate do |csv|
42+
csv << [ "id", "name", "type", "subtype", "balance", "currency", "created_at" ]
43+
44+
# Only export accounts belonging to this family
45+
@family.accounts.includes(:accountable).find_each do |account|
46+
csv << [
47+
account.id,
48+
account.name,
49+
account.accountable_type,
50+
account.subtype,
51+
account.balance.to_s,
52+
account.currency,
53+
account.created_at.iso8601
54+
]
55+
end
56+
end
57+
end
58+
59+
def generate_transactions_csv
60+
CSV.generate do |csv|
61+
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
62+
63+
# Only export transactions from accounts belonging to this family
64+
@family.transactions
65+
.includes(:category, :tags, entry: :account)
66+
.find_each do |transaction|
67+
csv << [
68+
transaction.entry.date.iso8601,
69+
transaction.entry.account.name,
70+
transaction.entry.amount.to_s,
71+
transaction.entry.name,
72+
transaction.category&.name,
73+
transaction.tags.pluck(:name).join(","),
74+
transaction.entry.notes,
75+
transaction.entry.currency
76+
]
77+
end
78+
end
79+
end
80+
81+
def generate_trades_csv
82+
CSV.generate do |csv|
83+
csv << [ "date", "account_name", "ticker", "quantity", "price", "amount", "currency" ]
84+
85+
# Only export trades from accounts belonging to this family
86+
@family.trades
87+
.includes(:security, entry: :account)
88+
.find_each do |trade|
89+
csv << [
90+
trade.entry.date.iso8601,
91+
trade.entry.account.name,
92+
trade.security.ticker,
93+
trade.qty.to_s,
94+
trade.price.to_s,
95+
trade.entry.amount.to_s,
96+
trade.currency
97+
]
98+
end
99+
end
100+
end
101+
102+
def generate_categories_csv
103+
CSV.generate do |csv|
104+
csv << [ "name", "color", "parent_category", "classification" ]
105+
106+
# Only export categories belonging to this family
107+
@family.categories.includes(:parent).find_each do |category|
108+
csv << [
109+
category.name,
110+
category.color,
111+
category.parent&.name,
112+
category.classification
113+
]
114+
end
115+
end
116+
end
117+
118+
def generate_ndjson
119+
lines = []
120+
121+
# Export accounts with full accountable data
122+
@family.accounts.includes(:accountable).find_each do |account|
123+
lines << {
124+
type: "Account",
125+
data: account.as_json(
126+
include: {
127+
accountable: {}
128+
}
129+
)
130+
}.to_json
131+
end
132+
133+
# Export categories
134+
@family.categories.find_each do |category|
135+
lines << {
136+
type: "Category",
137+
data: category.as_json
138+
}.to_json
139+
end
140+
141+
# Export tags
142+
@family.tags.find_each do |tag|
143+
lines << {
144+
type: "Tag",
145+
data: tag.as_json
146+
}.to_json
147+
end
148+
149+
# Export merchants (only family merchants)
150+
@family.merchants.find_each do |merchant|
151+
lines << {
152+
type: "Merchant",
153+
data: merchant.as_json
154+
}.to_json
155+
end
156+
157+
# Export transactions with full data
158+
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
159+
lines << {
160+
type: "Transaction",
161+
data: {
162+
id: transaction.id,
163+
entry_id: transaction.entry.id,
164+
account_id: transaction.entry.account_id,
165+
date: transaction.entry.date,
166+
amount: transaction.entry.amount,
167+
currency: transaction.entry.currency,
168+
name: transaction.entry.name,
169+
notes: transaction.entry.notes,
170+
excluded: transaction.entry.excluded,
171+
category_id: transaction.category_id,
172+
merchant_id: transaction.merchant_id,
173+
tag_ids: transaction.tag_ids,
174+
kind: transaction.kind,
175+
created_at: transaction.created_at,
176+
updated_at: transaction.updated_at
177+
}
178+
}.to_json
179+
end
180+
181+
# Export trades with full data
182+
@family.trades.includes(:security, entry: :account).find_each do |trade|
183+
lines << {
184+
type: "Trade",
185+
data: {
186+
id: trade.id,
187+
entry_id: trade.entry.id,
188+
account_id: trade.entry.account_id,
189+
security_id: trade.security_id,
190+
ticker: trade.security.ticker,
191+
date: trade.entry.date,
192+
qty: trade.qty,
193+
price: trade.price,
194+
amount: trade.entry.amount,
195+
currency: trade.currency,
196+
created_at: trade.created_at,
197+
updated_at: trade.updated_at
198+
}
199+
}.to_json
200+
end
201+
202+
# Export valuations
203+
@family.entries.valuations.includes(:account, :entryable).find_each do |entry|
204+
lines << {
205+
type: "Valuation",
206+
data: {
207+
id: entry.entryable.id,
208+
entry_id: entry.id,
209+
account_id: entry.account_id,
210+
date: entry.date,
211+
amount: entry.amount,
212+
currency: entry.currency,
213+
name: entry.name,
214+
created_at: entry.created_at,
215+
updated_at: entry.updated_at
216+
}
217+
}.to_json
218+
end
219+
220+
# Export budgets
221+
@family.budgets.find_each do |budget|
222+
lines << {
223+
type: "Budget",
224+
data: budget.as_json
225+
}.to_json
226+
end
227+
228+
# Export budget categories
229+
@family.budget_categories.includes(:budget, :category).find_each do |budget_category|
230+
lines << {
231+
type: "BudgetCategory",
232+
data: budget_category.as_json
233+
}.to_json
234+
end
235+
236+
lines.join("\n")
237+
end
238+
end

app/models/family_export.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class FamilyExport < ApplicationRecord
2+
belongs_to :family
3+
4+
has_one_attached :export_file
5+
6+
enum :status, {
7+
pending: "pending",
8+
processing: "processing",
9+
completed: "completed",
10+
failed: "failed"
11+
}, default: :pending, validate: true
12+
13+
scope :ordered, -> { order(created_at: :desc) }
14+
15+
def filename
16+
"maybe_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip"
17+
end
18+
19+
def downloadable?
20+
completed? && export_file.attached?
21+
end
22+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<%= turbo_frame_tag "family_exports",
2+
data: exports.any? { |e| e.pending? || e.processing? } ? {
3+
turbo_refresh_url: family_exports_path,
4+
turbo_refresh_interval: 3000
5+
} : {} do %>
6+
<div class="mt-4 space-y-3 max-h-96 overflow-y-auto">
7+
<% if exports.any? %>
8+
<% exports.each do |export| %>
9+
<div class="flex items-center justify-between bg-container p-4 rounded-lg border border-primary">
10+
<div>
11+
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
12+
<p class="text-xs text-secondary"><%= export.filename %></p>
13+
</div>
14+
15+
<% if export.processing? || export.pending? %>
16+
<div class="flex items-center gap-2 text-secondary">
17+
<div class="animate-spin h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
18+
<span class="text-sm">Exporting...</span>
19+
</div>
20+
<% elsif export.completed? %>
21+
<%= link_to download_family_export_path(export),
22+
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
23+
data: { turbo_frame: "_top" } do %>
24+
<%= icon "download", class: "w-5 h-5" %>
25+
<span class="text-sm font-medium">Download</span>
26+
<% end %>
27+
<% elsif export.failed? %>
28+
<div class="flex items-center gap-2 text-destructive">
29+
<%= icon "alert-circle", class: "w-4 h-4" %>
30+
<span class="text-sm">Failed</span>
31+
</div>
32+
<% end %>
33+
</div>
34+
<% end %>
35+
<% else %>
36+
<p class="text-sm text-secondary text-center py-4">No exports yet</p>
37+
<% end %>
38+
</div>
39+
<% end %>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<%= render "list", exports: @exports %>

0 commit comments

Comments
 (0)