From 6b6c3bd34369a96ca7b38139a75b76247ea2e7bc Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 11 May 2026 14:47:36 -0700 Subject: [PATCH] feat(exports): add attachment manifest (#1728) * feat(exports): add attachment manifest * fix(exports): include split parent receipts in manifest --- app/models/family/data_exporter.rb | 67 +++++++++++++++ test/models/family/data_exporter_test.rb | 105 ++++++++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 1c3446f2d..34b6beba5 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -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 = [] diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index b1f2e01eb..03376e08e 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -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