we-promise/sure · error · AccountStatement::InvalidUploadError
AccountStatement::InvalidUploadError
Error message
AccountStatement::InvalidUploadError
What it means
AccountStatement.prepare_upload! validates uploads before storing them: after reading the content, it sniffs the real content type via Marcel and runs three guards — this one (:143) fires when the detected type is application/pdf but the bytes do not start with the %PDF- magic marker (valid_pdf_content? is a plain content.start_with?("%PDF-") check). It raises AccountStatement::InvalidUploadError to reject files that merely claim or appear to be PDFs but aren't structurally PDFs at the byte level.
Source
Thrown at app/models/account_statement.rb:143
def reconciliation_statuses_for(statements, account:)
statement_list = statements.to_a
balance_lookup = balance_lookup_for(account, statement_list)
statement_list.to_h do |statement|
[ statement.id, statement.reconciliation_status(balance_lookup: balance_lookup) ]
end
end
def prepare_upload!(file)
filename = file.original_filename.to_s
content = read_upload_content!(file)
byte_size = content.bytesize
raise InvalidUploadError if byte_size.zero?
content_type = detected_content_type(content:, filename:, declared_content_type: file.content_type)
raise InvalidUploadError unless allowed_upload?(filename:, content_type:)
raise InvalidUploadError if content_type == "application/pdf" && !valid_pdf_content?(content)
PreparedUpload.new(
content: content,
filename: filename,
content_type: content_type,
byte_size: byte_size,
checksum: Digest::MD5.base64digest(content),
content_sha256: Digest::SHA256.hexdigest(content)
)
end
def detected_content_type(content:, filename:, declared_content_type:)
Marcel::MimeType.for(
StringIO.new(content),
name: filename,
declared_type: declared_content_type.presence
)
endView on GitHub (pinned to e69894adb9)
Solutions
- Re-download the statement directly from the bank in a fresh session and verify it opens in a real PDF viewer before uploading
- Check the magic bytes yourself: head -c 5 statement.pdf should print %PDF-
- If the file is genuinely another format (PNG/CSV), give it the correct extension so it goes down the right allowed-type path instead of the PDF one
- Regenerate broken fixtures/tests with a minimal valid PDF (e.g. "%PDF-1.4…%%EOF" minimal document)
Example fix
# before
file = Tempfile.new(["statement", ".pdf"])
file.write("<html>Session expired</html>") # bytes are HTML, name says .pdf
AccountStatement.prepare_upload!(file) # => AccountStatement::InvalidUploadError
# after
# verify and upload a real PDF
raise "not a PDF" unless content.start_with?("%PDF-")
File.binwrite("statement.pdf", content) # genuine %PDF bytes
AccountStatement.prepare_upload!(uploaded_real_pdf) Defensive patterns
Strategy: validation
Validate before calling
# Before uploading a .pdf
content = File.binread(path)
raise "not a PDF" unless content.start_with?("%PDF-")
raise "empty file" if content.bytesize.zero? Type guard
def real_pdf?(path) content = File.binread(path, 5) content == "%PDF-" end
Try / catch
rescue AccountStatement::InvalidUploadError
# tell the user the file is not a real PDF; ask them to re-download and verify
render json: { error: "File is not a valid PDF. Re-download it and confirm it opens in a viewer." }, status: :unprocessable_entity
end Prevention
- Verify downloaded bank PDFs open in a real viewer before uploading
- Check magic bytes (head -c 5 file.pdf) when automating uploads
- Beware bank portals serving HTML error pages with .pdf URLs on session expiry
- Generate test fixtures from real PDFs, not renamed text files
When it happens
Trigger: A .pdf extension whose bytes are actually HTML (a bank's error page saved as .pdf) — note Marcel can smell HTML-in-pdf-clothing via content; a truncated PDF download (connection dropped mid-download, first bytes intact but header claims pdf) where Marcel still detects pdf from name+magic but content check differs; a text file renamed to statement.pdf; a zero-byte-then-garbage file where content sniffing leaned on the filename.
Common situations: Bank portals that serve an HTML login/error page with a .pdf URL when the session expired; wget/curl downloads interrupted and silently saved partial files; files passed through Excel or preview apps that 'helpfully' re-encoded them; test fixtures generated with Faker text but a .pdf name.
Related errors
- Statement file has already been uploaded
- file_too_large
- invalid_file_type
- record_not_found
- validation_failed
AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21).
Data as JSON: /api/errors/48f7805ba38e6c29.
Report an issue: GitHub.