feat(exports): add attachment manifest (#1728)

* feat(exports): add attachment manifest

* fix(exports): include split parent receipts in manifest
This commit is contained in:
ghost
2026-05-11 14:47:36 -07:00
committed by GitHub
parent f50c151e21
commit 6b6c3bd343
2 changed files with 171 additions and 1 deletions

View File

@@ -29,6 +29,10 @@ class Family::DataExporter
zipfile.put_next_entry("rules.csv")
zipfile.write generate_rules_csv
# Add attachment manifest metadata. Binary file payloads are not included.
zipfile.put_next_entry("attachments.json")
zipfile.write generate_attachments_manifest
# Add all.ndjson
zipfile.put_next_entry("all.ndjson")
zipfile.write generate_ndjson
@@ -138,6 +142,69 @@ class Family::DataExporter
end
end
def generate_attachments_manifest
{
version: 1,
binary_included: false,
attachments: attachment_manifest_items
}.to_json
end
def attachment_manifest_items
(transaction_attachment_manifest_items + family_document_attachment_manifest_items)
.sort_by { |item| [ item[:record_type], item[:record_id].to_s, item[:filename].to_s, item[:id].to_s ] }
end
def transaction_attachment_manifest_items
@family.transactions
.with_attached_attachments
.includes(:attachments_attachments, entry: :account)
.flat_map do |transaction|
transaction.attachments.map do |attachment|
attachment_manifest_item(
attachment,
record_type: "Transaction",
record_id: transaction.id,
extra: {
entry_id: transaction.entry.id,
account_id: transaction.entry.account_id
}
)
end
end
end
def family_document_attachment_manifest_items
@family.family_documents.with_attached_file.filter_map do |document|
next unless document.file.attached?
attachment_manifest_item(
document.file.attachment,
record_type: "FamilyDocument",
record_id: document.id,
extra: {
status: document.status
}
)
end
end
def attachment_manifest_item(attachment, record_type:, record_id:, extra: {})
blob = attachment.blob
{
id: attachment.id,
record_type: record_type,
record_id: record_id,
name: attachment.name,
filename: blob.filename.to_s,
content_type: blob.content_type,
byte_size: blob.byte_size,
checksum: blob.checksum,
binary_included: false,
created_at: attachment.created_at
}.merge(extra)
end
def generate_ndjson
lines = []

View File

@@ -47,7 +47,7 @@ class Family::DataExporterTest < ActiveSupport::TestCase
assert zip_data.is_a?(StringIO)
# Check that the zip contains all expected files
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "all.ndjson" ]
expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "rules.csv", "attachments.json", "all.ndjson" ]
Zip::File.open_buffer(zip_data) do |zip|
actual_files = zip.entries.map(&:name)
@@ -55,6 +55,109 @@ class Family::DataExporterTest < ActiveSupport::TestCase
end
end
test "exports attachment manifest metadata without binary payloads" do
entry = @account.entries.create!(
name: "Receipt Transaction",
amount: 12.34,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
transaction = entry.transaction
transaction.attachments.attach(
io: StringIO.new("receipt bytes"),
filename: "receipt.pdf",
content_type: "application/pdf"
)
family_document = @family.family_documents.create!(
filename: "statement.pdf",
status: "ready"
)
family_document.file.attach(
io: StringIO.new("statement bytes"),
filename: "statement.pdf",
content_type: "application/pdf"
)
other_account = @other_family.accounts.create!(
name: "Other Attachment Account",
accountable: Depository.new,
balance: 0,
currency: "USD"
)
other_entry = other_account.entries.create!(
name: "Other Receipt",
amount: 1,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
other_entry.transaction.attachments.attach(
io: StringIO.new("other bytes"),
filename: "other-receipt.pdf",
content_type: "application/pdf"
)
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
manifest = JSON.parse(zip.read("attachments.json"))
attachments = manifest["attachments"]
filenames = attachments.map { |attachment| attachment["filename"] }
assert_equal 1, manifest["version"]
assert_equal false, manifest["binary_included"]
assert_includes filenames, "receipt.pdf"
assert_includes filenames, "statement.pdf"
refute_includes filenames, "other-receipt.pdf"
transaction_item = attachments.find { |attachment| attachment["record_type"] == "Transaction" }
assert_equal transaction.id, transaction_item["record_id"]
assert_equal entry.id, transaction_item["entry_id"]
assert_equal @account.id, transaction_item["account_id"]
assert_equal "attachments", transaction_item["name"]
assert_equal "application/pdf", transaction_item["content_type"]
assert_equal false, transaction_item["binary_included"]
document_item = attachments.find { |attachment| attachment["record_type"] == "FamilyDocument" }
assert_equal family_document.id, document_item["record_id"]
assert_equal "ready", document_item["status"]
assert_equal "file", document_item["name"]
assert_equal false, document_item["binary_included"]
end
end
test "exports split parent receipts in attachment manifest" do
split_parent = create_transaction_entry(
@account,
amount: 60,
date: Date.parse("2024-01-25"),
name: "Split parent receipt"
)
split_parent.entryable.attachments.attach(
io: StringIO.new("split parent receipt bytes"),
filename: "split-parent-receipt.pdf",
content_type: "application/pdf"
)
split_parent.split!([
{ name: "Split child", amount: 60, category_id: @category.id }
])
zip_data = @exporter.generate_export
Zip::File.open_buffer(zip_data) do |zip|
manifest = JSON.parse(zip.read("attachments.json"))
attachment = manifest["attachments"].find { |item| item["filename"] == "split-parent-receipt.pdf" }
assert attachment
assert_equal "Transaction", attachment["record_type"]
assert_equal split_parent.entryable.id, attachment["record_id"]
assert_equal split_parent.id, attachment["entry_id"]
assert_equal @account.id, attachment["account_id"]
end
end
test "generates valid CSV files" do
zip_data = @exporter.generate_export