diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb
index 7eeb5ee66..0c68d1dbe 100644
--- a/app/components/DS/buttonish.rb
+++ b/app/components/DS/buttonish.rb
@@ -80,7 +80,7 @@ class DS::Buttonish < DesignSystemComponent
merged_base_classes,
full_width ? "w-full justify-center" : nil,
container_size_classes,
- size_data.dig(:text_classes),
+ icon_only? ? nil : size_data.dig(:text_classes),
variant_data.dig(:container_classes)
)
end
@@ -108,7 +108,7 @@ class DS::Buttonish < DesignSystemComponent
end
def icon_only?
- variant.in?([ :icon, :icon_inverse ])
+ variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?)
end
private
diff --git a/app/controllers/transaction_attachments_controller.rb b/app/controllers/transaction_attachments_controller.rb
new file mode 100644
index 000000000..6aa39bffd
--- /dev/null
+++ b/app/controllers/transaction_attachments_controller.rb
@@ -0,0 +1,98 @@
+class TransactionAttachmentsController < ApplicationController
+ before_action :set_transaction
+ before_action :set_attachment, only: [ :show, :destroy ]
+
+ def show
+ disposition = params[:disposition] == "attachment" ? "attachment" : "inline"
+ redirect_to rails_blob_url(@attachment, disposition: disposition)
+ end
+
+ def create
+ attachments = attachment_params
+
+ if attachments.present?
+ @transaction.with_lock do
+ # Check attachment count limit before attaching
+ current_count = @transaction.attachments.count
+ new_count = attachments.is_a?(Array) ? attachments.length : 1
+
+ if current_count + new_count > Transaction::MAX_ATTACHMENTS_PER_TRANSACTION
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) }
+ format.turbo_stream { flash.now[:alert] = t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) }
+ end
+ return
+ end
+
+ existing_ids = @transaction.attachments.pluck(:id)
+ attachment_proxy = @transaction.attachments.attach(attachments)
+
+ if @transaction.valid?
+ count = new_count
+ message = count == 1 ? t("transactions.attachments.uploaded_one") : t("transactions.attachments.uploaded_many", count: count)
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), notice: message }
+ format.turbo_stream { flash.now[:notice] = message }
+ end
+ else
+ # Remove invalid attachments
+ newly_added = Array(attachment_proxy).reject { |a| existing_ids.include?(a.id) }
+ newly_added.each(&:purge)
+ error_messages = @transaction.errors.full_messages_for(:attachments).join(", ")
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.failed_upload", error: error_messages) }
+ format.turbo_stream { flash.now[:alert] = t("transactions.attachments.failed_upload", error: error_messages) }
+ end
+ end
+ end
+ else
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.no_files_selected") }
+ format.turbo_stream { flash.now[:alert] = t("transactions.attachments.no_files_selected") }
+ end
+ end
+ rescue => e
+ logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.upload_failed") }
+ format.turbo_stream { flash.now[:alert] = t("transactions.attachments.upload_failed") }
+ end
+ end
+
+ def destroy
+ @attachment.purge
+ message = t("transactions.attachments.attachment_deleted")
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), notice: message }
+ format.turbo_stream { flash.now[:notice] = message }
+ end
+ rescue => e
+ logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
+ respond_to do |format|
+ format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.delete_failed") }
+ format.turbo_stream { flash.now[:alert] = t("transactions.attachments.delete_failed") }
+ end
+ end
+
+ private
+
+ def set_transaction
+ @transaction = Current.family.transactions.find(params[:transaction_id])
+ end
+
+ def set_attachment
+ @attachment = @transaction.attachments.find(params[:id])
+ end
+
+ def attachment_params
+ if params.has_key?(:attachments)
+ Array(params.fetch(:attachments, [])).reject(&:blank?).map do |param|
+ param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param
+ end
+ elsif params.has_key?(:attachment)
+ param = params[:attachment]
+ return nil if param.blank?
+ param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param
+ end
+ end
+end
diff --git a/app/javascript/controllers/attachment_upload_controller.js b/app/javascript/controllers/attachment_upload_controller.js
new file mode 100644
index 000000000..ba1632d75
--- /dev/null
+++ b/app/javascript/controllers/attachment_upload_controller.js
@@ -0,0 +1,63 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class AttachmentUploadController extends Controller {
+ static targets = ["fileInput", "submitButton", "fileName", "uploadText"]
+ static values = {
+ maxFiles: Number,
+ maxSize: Number
+ }
+
+ connect() {
+ this.updateSubmitButton()
+ }
+
+ triggerFileInput() {
+ this.fileInputTarget.click()
+ }
+
+ updateSubmitButton() {
+ const files = Array.from(this.fileInputTarget.files)
+ const hasFiles = files.length > 0
+
+ // Basic validation hints (server validates definitively)
+ let isValid = hasFiles
+ let errorMessage = ""
+
+ if (hasFiles) {
+ if (this.hasUploadTextTarget) this.uploadTextTarget.classList.add("hidden")
+ if (this.hasFileNameTarget) {
+ const filenames = files.map(f => f.name).join(", ")
+ const textElement = this.fileNameTarget.querySelector("p")
+ if (textElement) textElement.textContent = filenames
+ this.fileNameTarget.classList.remove("hidden")
+ }
+
+ // Check file count
+ if (files.length > this.maxFilesValue) {
+ isValid = false
+ errorMessage = `Too many files (max ${this.maxFilesValue})`
+ }
+
+ // Check file sizes
+ const oversizedFiles = files.filter(file => file.size > this.maxSizeValue)
+ if (oversizedFiles.length > 0) {
+ isValid = false
+ errorMessage = `File too large (max ${Math.round(this.maxSizeValue / 1024 / 1024)}MB)`
+ }
+ } else {
+ if (this.hasUploadTextTarget) this.uploadTextTarget.classList.remove("hidden")
+ if (this.hasFileNameTarget) this.fileNameTarget.classList.add("hidden")
+ }
+
+ this.submitButtonTarget.disabled = !isValid
+
+ if (hasFiles && isValid) {
+ const count = files.length
+ this.submitButtonTarget.textContent = count === 1 ? "Upload 1 file" : `Upload ${count} files`
+ } else if (errorMessage) {
+ this.submitButtonTarget.textContent = errorMessage
+ } else {
+ this.submitButtonTarget.textContent = "Upload"
+ }
+ }
+}
diff --git a/app/models/transaction.rb b/app/models/transaction.rb
index 47a9ce5a5..cabe4cede 100644
--- a/app/models/transaction.rb
+++ b/app/models/transaction.rb
@@ -7,6 +7,23 @@ class Transaction < ApplicationRecord
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
+ # File attachments (receipts, invoices, etc.) using Active Storage
+ # Supports images (JPEG, PNG, GIF, WebP) and PDFs up to 10MB each
+ # Maximum 10 attachments per transaction, family-scoped access
+ has_many_attached :attachments do |attachable|
+ attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ]
+ end
+
+ # Attachment validation constants
+ MAX_ATTACHMENTS_PER_TRANSACTION = 10
+ MAX_ATTACHMENT_SIZE = 10.megabytes
+ ALLOWED_CONTENT_TYPES = %w[
+ image/jpeg image/jpg image/png image/gif image/webp
+ application/pdf
+ ].freeze
+
+ validate :validate_attachments, if: -> { attachments.attached? }
+
accepts_nested_attributes_for :taggings, allow_destroy: true
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
@@ -178,6 +195,26 @@ class Transaction < ApplicationRecord
private
+ def validate_attachments
+ # Check attachment count limit
+ if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION
+ errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION)
+ end
+
+ # Validate each attachment
+ attachments.each_with_index do |attachment, index|
+ # Check file size
+ if attachment.byte_size > MAX_ATTACHMENT_SIZE
+ errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte)
+ end
+
+ # Check content type
+ unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type)
+ errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type)
+ end
+ end
+ end
+
def potential_posted_match_data
return nil unless extra.is_a?(Hash)
extra["potential_posted_match"]
diff --git a/app/views/transaction_attachments/create.turbo_stream.erb b/app/views/transaction_attachments/create.turbo_stream.erb
new file mode 100644
index 000000000..571379426
--- /dev/null
+++ b/app/views/transaction_attachments/create.turbo_stream.erb
@@ -0,0 +1,4 @@
+<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction } %>
+<% flash_notification_stream_items.each do |item| %>
+ <%= item %>
+<% end %>
diff --git a/app/views/transaction_attachments/destroy.turbo_stream.erb b/app/views/transaction_attachments/destroy.turbo_stream.erb
new file mode 100644
index 000000000..571379426
--- /dev/null
+++ b/app/views/transaction_attachments/destroy.turbo_stream.erb
@@ -0,0 +1,4 @@
+<%= turbo_stream.replace "transaction_attachments_#{@transaction.id}", partial: "transactions/attachments", locals: { transaction: @transaction } %>
+<% flash_notification_stream_items.each do |item| %>
+ <%= item %>
+<% end %>
diff --git a/app/views/transactions/_attachments.html.erb b/app/views/transactions/_attachments.html.erb
new file mode 100644
index 000000000..7dc5df74c
--- /dev/null
+++ b/app/views/transactions/_attachments.html.erb
@@ -0,0 +1,122 @@
+
+
+ <% if transaction.attachments.count < Transaction::MAX_ATTACHMENTS_PER_TRANSACTION %>
+ <%= styled_form_with url: transaction_attachments_path(transaction),
+ method: :post,
+ multipart: true,
+ local: true,
+ class: "mb-4",
+ data: {
+ controller: "attachment-upload",
+ attachment_upload_max_files_value: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION - transaction.attachments.count,
+ attachment_upload_max_size_value: Transaction::MAX_ATTACHMENT_SIZE
+ } do |form| %>
+
+
+
+
+
+ <%= icon "plus", size: "lg", class: "mb-2 text-secondary" %>
+
<%= t(".browse_to_add") %>
+
+
+
+ <%= icon "file-text", size: "lg", class: "mb-2 text-secondary" %>
+
+
+
+ <%= form.file_field :attachments,
+ multiple: true,
+ accept: Transaction::ALLOWED_CONTENT_TYPES.join(','),
+ class: "hidden",
+ data: {
+ attachment_upload_target: "fileInput",
+ action: "change->attachment-upload#updateSubmitButton"
+ } %>
+
+
+
+ <%= t(".select_up_to",
+ count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION,
+ size: Transaction::MAX_ATTACHMENT_SIZE / 1.megabyte,
+ used: transaction.attachments.count) %>
+
+
+
+
+ <%= render DS::Button.new(
+ text: t(".upload"),
+ variant: :primary,
+ size: :sm,
+ data: { attachment_upload_target: "submitButton" }
+ ) %>
+
+
+ <% end %>
+ <% else %>
+
+ <%= icon "alert-circle", size: "sm", color: "warning", class: "mt-0.5" %>
+
+ <%= t(".max_reached", count: transaction.attachments.count, max: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) %>
+
+
+ <% end %>
+
+
+ <% if transaction.attachments.any? %>
+
+
<%= t(".files", count: transaction.attachments.count) %>
+
+ <% transaction.attachments.each do |attachment| %>
+
+
+
+ <% if attachment.image? %>
+ <%= icon "image", size: "sm", color: "secondary" %>
+ <% else %>
+ <%= icon "file", size: "sm", color: "secondary" %>
+ <% end %>
+
+
+
<%= attachment.filename %>
+
<%= number_to_human_size(attachment.byte_size) %>
+
+
+
+
+ <%= render DS::Link.new(
+ href: transaction_attachment_path(transaction, attachment, disposition: :inline),
+ variant: :outline,
+ size: :sm,
+ icon: "eye",
+ text: "",
+ target: "_blank"
+ ) %>
+
+ <%= render DS::Link.new(
+ href: transaction_attachment_path(transaction, attachment, disposition: :attachment),
+ variant: :outline,
+ size: :sm,
+ icon: "download",
+ text: "",
+ data: { turbo: false }
+ ) %>
+
+ <%= render DS::Button.new(
+ href: transaction_attachment_path(transaction, attachment),
+ method: :delete,
+ variant: :outline_destructive,
+ size: :sm,
+ icon: "trash-2",
+ confirm: CustomConfirm.for_resource_deletion("attachment")
+ ) %>
+
+
+ <% end %>
+
+
+ <% else %>
+
<%= t(".no_attachments") %>
+ <% end %>
+
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 8512572d1..4f520f976 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -124,6 +124,11 @@
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
+
+ <% dialog.with_section(title: t(".attachments")) do %>
+ <%= render "transactions/attachments", transaction: @entry.transaction %>
+ <% end %>
+
<% if (details = build_transaction_extra_details(@entry)) %>
<% dialog.with_section(title: "Additional details", open: false) do %>
diff --git a/config/initializers/active_storage_authorization.rb b/config/initializers/active_storage_authorization.rb
new file mode 100644
index 000000000..7766dc3c3
--- /dev/null
+++ b/config/initializers/active_storage_authorization.rb
@@ -0,0 +1,45 @@
+# Override Active Storage blob serving to enforce authorization
+Rails.application.config.to_prepare do
+ module ActiveStorageAttachmentAuthorization
+ extend ActiveSupport::Concern
+
+ included do
+ include Authentication
+ before_action :authorize_transaction_attachment, if: :transaction_attachment?
+ end
+
+ private
+
+ def authorize_transaction_attachment
+ attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob)
+ return unless attachment&.record_type == "Transaction"
+
+ transaction = attachment.record
+
+ # Check if current user has access to this transaction's family
+ unless Current.family == transaction.entry.account.family
+ raise ActiveRecord::RecordNotFound
+ end
+ end
+
+ def transaction_attachment?
+ return false unless authorized_blob
+
+ attachment = ActiveStorage::Attachment.find_by(blob: authorized_blob)
+ attachment&.record_type == "Transaction"
+ end
+
+ def authorized_blob
+ @blob || @representation&.blob
+ end
+ end
+
+ [
+ ActiveStorage::Blobs::RedirectController,
+ ActiveStorage::Blobs::ProxyController,
+ ActiveStorage::Representations::RedirectController,
+ ActiveStorage::Representations::ProxyController
+ ].each do |controller|
+ controller.include ActiveStorageAttachmentAuthorization
+ end
+end
diff --git a/config/locales/models/transaction/en.yml b/config/locales/models/transaction/en.yml
new file mode 100644
index 000000000..7359a69b4
--- /dev/null
+++ b/config/locales/models/transaction/en.yml
@@ -0,0 +1,11 @@
+---
+en:
+ activerecord:
+ errors:
+ models:
+ transaction:
+ attributes:
+ attachments:
+ too_many: "cannot exceed %{max} files per transaction"
+ too_large: "file %{index} is too large (maximum %{max_mb}MB)"
+ invalid_format: "file %{index} has unsupported format (%{file_format})"
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index 49bb4e8d9..f01b15e98 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -31,6 +31,7 @@ en:
balances, and cannot be undone.
delete_title: Delete transaction
details: Details
+ attachments: Attachments
exclude: Exclude
exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
activity_type: Activity Type
@@ -206,3 +207,21 @@ en:
less_than: less than
form:
toggle_selection_checkboxes: Toggle all checkboxes
+ attachments:
+ cannot_exceed: "Cannot exceed %{count} attachments per transaction"
+ uploaded_one: "Attachment uploaded successfully"
+ uploaded_many: "%{count} attachments uploaded successfully"
+ failed_upload: "Failed to upload attachment: %{error}"
+ no_files_selected: "No files selected for upload"
+ attachment_deleted: "Attachment deleted successfully"
+ failed_delete: "Failed to delete attachment: %{error}"
+ upload_failed: "Failed to upload attachment. Please try again or contact support."
+ delete_failed: "Failed to delete attachment. Please try again or contact support."
+ upload: "Upload"
+ no_attachments: "No attachments yet"
+ select_up_to: "Select up to %{count} files (images or PDFs, max %{size}MB each) • %{used} of %{count} used"
+ files:
+ one: "File (1)"
+ other: "Files (%{count})"
+ browse_to_add: "Browse to add files"
+ max_reached: "Maximum file limit reached (%{count}/%{max}). Delete an existing file to upload another."
diff --git a/config/routes.rb b/config/routes.rb
index 3bdeeb35d..e3b494761 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -272,6 +272,7 @@ Rails.application.routes.draw do
resource :transfer_match, only: %i[new create]
resource :pending_duplicate_merges, only: %i[new create]
resource :category, only: :update, controller: :transaction_categories
+ resources :attachments, only: %i[show create destroy], controller: :transaction_attachments
collection do
delete :clear_filter
diff --git a/test/controllers/transaction_attachments_controller_test.rb b/test/controllers/transaction_attachments_controller_test.rb
new file mode 100644
index 000000000..fdb6e22b9
--- /dev/null
+++ b/test/controllers/transaction_attachments_controller_test.rb
@@ -0,0 +1,144 @@
+require "test_helper"
+
+class TransactionAttachmentsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:family_admin)
+ @entry = entries(:transaction)
+ @transaction = @entry.entryable
+ end
+
+ test "should upload attachment to transaction" do
+ file = fixture_file_upload("test.txt", "application/pdf")
+
+ assert_difference "@transaction.attachments.count", 1 do
+ post transaction_attachments_path(@transaction), params: { attachment: file }
+ end
+
+ assert_redirected_to transaction_path(@transaction)
+ assert_match "Attachment uploaded successfully", flash[:notice]
+ end
+
+ test "should upload multiple attachments to transaction" do
+ file1 = fixture_file_upload("test.txt", "application/pdf")
+ file2 = fixture_file_upload("test.txt", "image/jpeg")
+
+ assert_difference "@transaction.attachments.count", 2 do
+ post transaction_attachments_path(@transaction), params: { attachments: [ file1, file2 ] }
+ end
+
+ assert_redirected_to transaction_path(@transaction)
+ assert_match "2 attachments uploaded successfully", flash[:notice]
+ end
+
+ test "should ignore blank attachments in array" do
+ file = fixture_file_upload("test.txt", "application/pdf")
+
+ assert_difference "@transaction.attachments.count", 1 do
+ # Simulate Rails behavior where an empty string is often sent in the array
+ post transaction_attachments_path(@transaction), params: { attachments: [ file, "" ] }
+ end
+
+ assert_redirected_to transaction_path(@transaction)
+ assert_match "Attachment uploaded successfully", flash[:notice] # Should be singular
+ end
+
+ test "should handle upload with no files" do
+ assert_no_difference "@transaction.attachments.count" do
+ post transaction_attachments_path(@transaction), params: {}
+ end
+
+ assert_redirected_to transaction_path(@transaction)
+ assert_match "No files selected for upload", flash[:alert]
+ end
+
+ test "should reject unsupported file types" do
+ file = fixture_file_upload("test.txt", "text/plain")
+
+ assert_no_difference "@transaction.attachments.count" do
+ post transaction_attachments_path(@transaction), params: { attachment: file }
+ end
+
+ assert_redirected_to transaction_path(@transaction)
+ assert_match "unsupported format", flash[:alert]
+ end
+
+ test "should reject exceeding attachment count limit" do
+ # Fill up to the limit
+ (Transaction::MAX_ATTACHMENTS_PER_TRANSACTION).times do |i|
+ @transaction.attachments.attach(
+ io: StringIO.new("content #{i}"),
+ filename: "file#{i}.pdf",
+ content_type: "application/pdf"
+ )
+ end
+
+ file = fixture_file_upload("test.txt", "application/pdf")
+
+ assert_no_difference "@transaction.attachments.count" do
+ post transaction_attachments_path(@transaction), params: { attachment: file }
+ end
+
+ assert_redirected_to transaction_path(@transaction)
+ assert_match "Cannot exceed #{Transaction::MAX_ATTACHMENTS_PER_TRANSACTION} attachments", flash[:alert]
+ end
+
+ test "should show attachment for authorized user" do
+ @transaction.attachments.attach(
+ io: StringIO.new("test content"),
+ filename: "test.pdf",
+ content_type: "application/pdf"
+ )
+
+ attachment = @transaction.attachments.first
+ get transaction_attachment_path(@transaction, attachment)
+
+ assert_response :redirect
+ end
+
+ test "should upload attachment via turbo_stream" do
+ file = fixture_file_upload("test.txt", "application/pdf")
+
+ assert_difference "@transaction.attachments.count", 1 do
+ post transaction_attachments_path(@transaction), params: { attachment: file }, as: :turbo_stream
+ end
+
+ assert_response :success
+ assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body)
+ assert_match(/turbo-stream action="append" target="notification-tray"/, response.body)
+ assert_match("Attachment uploaded successfully", response.body)
+ end
+
+ test "should show attachment inline" do
+ @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf")
+ attachment = @transaction.attachments.first
+
+ get transaction_attachment_path(@transaction, attachment, disposition: :inline)
+
+ assert_response :redirect
+ assert_match(/disposition=inline/, response.redirect_url)
+ end
+
+ test "should show attachment as download" do
+ @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf")
+ attachment = @transaction.attachments.first
+
+ get transaction_attachment_path(@transaction, attachment, disposition: :attachment)
+
+ assert_response :redirect
+ assert_match(/disposition=attachment/, response.redirect_url)
+ end
+
+ test "should delete attachment via turbo_stream" do
+ @transaction.attachments.attach(io: StringIO.new("test"), filename: "test.pdf", content_type: "application/pdf")
+ attachment = @transaction.attachments.first
+
+ assert_difference "@transaction.attachments.count", -1 do
+ delete transaction_attachment_path(@transaction, attachment), as: :turbo_stream
+ end
+
+ assert_response :success
+ assert_match(/turbo-stream action="replace" target="transaction_attachments_#{@transaction.id}"/, response.body)
+ assert_match(/turbo-stream action="append" target="notification-tray"/, response.body)
+ assert_match("Attachment deleted successfully", response.body)
+ end
+end
diff --git a/test/fixtures/files/test.txt b/test/fixtures/files/test.txt
new file mode 100644
index 000000000..69fadbfa4
--- /dev/null
+++ b/test/fixtures/files/test.txt
@@ -0,0 +1 @@
+This is a test file for attachment uploads.
diff --git a/test/integration/active_storage_authorization_test.rb b/test/integration/active_storage_authorization_test.rb
new file mode 100644
index 000000000..75698a536
--- /dev/null
+++ b/test/integration/active_storage_authorization_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+
+class ActiveStorageAuthorizationTest < ActionDispatch::IntegrationTest
+ setup do
+ @user_a = users(:family_admin) # In dylan_family
+ @user_b = users(:empty) # In empty family
+
+ @transaction_a = transactions(:one) # Assuming it belongs to dylan_family via its entry/account
+ @transaction_a.attachments.attach(
+ io: StringIO.new("Family A Secret Receipt"),
+ filename: "receipt.pdf",
+ content_type: "application/pdf"
+ )
+ @attachment_a = @transaction_a.attachments.first
+ end
+
+ test "user can access attachments within their own family" do
+ sign_in @user_a
+
+ # Get the redirect URL from our controller
+ get transaction_attachment_path(@transaction_a, @attachment_a)
+ assert_response :redirect
+
+ # Follow the redirect to ActiveStorage::Blobs::RedirectController
+ follow_redirect!
+
+ # In test/local environment, it will redirect again to a disk URL
+ assert_response :redirect
+ assert_match(/rails\/active_storage\/disk/, response.header["Location"])
+ end
+
+ test "user cannot access attachments from a different family" do
+ sign_in @user_b
+
+ # Even if they find the signed global ID (which is hard but possible),
+ # the monkey patch should block them at the blob controller level.
+ # We bypass our controller and go straight to the blob serving URL to test the security layer
+ get rails_blob_path(@attachment_a)
+
+ # The monkey patch raises ActiveRecord::RecordNotFound which rails converts to 404
+ assert_response :not_found
+ end
+
+ test "user cannot access variants from a different family" do
+ # Attach an image to test variants
+ file = File.open(Rails.root.join("test/fixtures/files/square-placeholder.png"))
+ @transaction_a.attachments.attach(io: file, filename: "test.png", content_type: "image/png")
+ attachment = @transaction_a.attachments.last
+ variant = attachment.variant(resize_to_limit: [ 100, 100 ]).processed
+
+ sign_in @user_b
+
+ # Straight to the representation URL
+ get rails_representation_path(variant)
+
+ assert_response :not_found
+ end
+end
diff --git a/test/models/transaction_attachment_validation_test.rb b/test/models/transaction_attachment_validation_test.rb
new file mode 100644
index 000000000..16182ccdb
--- /dev/null
+++ b/test/models/transaction_attachment_validation_test.rb
@@ -0,0 +1,61 @@
+require "test_helper"
+
+class TransactionAttachmentValidationTest < ActiveSupport::TestCase
+ setup do
+ @transaction = transactions(:one)
+ end
+
+ test "should validate attachment content types" do
+ # Valid content type should pass
+ @transaction.attachments.attach(
+ io: StringIO.new("valid content"),
+ filename: "test.pdf",
+ content_type: "application/pdf"
+ )
+ assert @transaction.valid?
+
+ # Invalid content type should fail
+ @transaction.attachments.attach(
+ io: StringIO.new("invalid content"),
+ filename: "test.txt",
+ content_type: "text/plain"
+ )
+ assert_not @transaction.valid?
+ assert_includes @transaction.errors.full_messages_for(:attachments).join, "unsupported format"
+ end
+
+ test "should validate attachment count limit" do
+ # Fill up to the limit
+ Transaction::MAX_ATTACHMENTS_PER_TRANSACTION.times do |i|
+ @transaction.attachments.attach(
+ io: StringIO.new("content #{i}"),
+ filename: "file#{i}.pdf",
+ content_type: "application/pdf"
+ )
+ end
+ assert @transaction.valid?
+
+ # Exceeding the limit should fail
+ @transaction.attachments.attach(
+ io: StringIO.new("extra content"),
+ filename: "extra.pdf",
+ content_type: "application/pdf"
+ )
+ assert_not @transaction.valid?
+ assert_includes @transaction.errors.full_messages_for(:attachments).join, "cannot exceed"
+ end
+
+ test "should validate attachment file size" do
+ # Create a mock large attachment
+ large_content = "x" * (Transaction::MAX_ATTACHMENT_SIZE + 1)
+
+ @transaction.attachments.attach(
+ io: StringIO.new(large_content),
+ filename: "large.pdf",
+ content_type: "application/pdf"
+ )
+
+ assert_not @transaction.valid?
+ assert_includes @transaction.errors.full_messages_for(:attachments).join, "too large"
+ end
+end