Skip to content

Add add-attachments and replace-attachments commands - #40

Open
awoods187 wants to merge 4 commits into
dualentry:mainfrom
awoods187:andyw/dev-12110-cli-no-attachment-upload-command-11-resources-expose-post
Open

awoods187 wants to merge 4 commits into
dualentry:mainfrom
awoods187:andyw/dev-12110-cli-no-attachment-upload-command-11-resources-expose-post

Conversation

@awoods187

@awoods187 awoods187 commented Oct 30, 2026 •

Copy link
Copy Markdown

Closes DEV-12110

Summary

Thirteen transaction types accept POST /{number}/attachments/ in the public API, and invoices also accept POST /{number}/attachments/replace/. The CLI couldn't upload to any of them, except statistical journals, which had their own command. This PR adds the commands through the resource factory, so uploading a receipt or supporting document is one shell command:

dualentry bills add-attachments 1042 -f receipt.pdf -f approval.eml
dualentry invoices replace-attachments 318 -f signed-invoice.pdf

Changes

  • add-attachments on invoices, bills, sales orders, purchase orders, journal entries, cash sales, direct expenses, customer deposits, customer refunds, customer prepayments, customer prepayment applications, vendor payments, and vendor refunds. replace-attachments on invoices only, since that's the only resource with the route. The list matches the v2 API's routes, and a registration test fails if a resource gains a command the API doesn't have.
  • Factory: new has_attachments / has_replace_attachments flags. They raise at registration if replace is set without add, or attachments without record numbers.
  • All-or-nothing upload. The API stores each file before it checks the next one, so a bad last file would leave the earlier ones orphaned. The CLI reads every file first and sends nothing if any is empty or over 10 MiB. Each read stops one byte past the limit, so an oversized file is never loaded whole, and folders are rejected.
  • Record number only. In list output, IN-42 is the record's internal ID, but the upload route takes the record number. Stripping the prefix would upload to whichever record has number 42, so the command rejects prefixed IDs with an error that says what to pass. Statistical journals are the exception: their SJ- prefix wraps the number, so SJ-3 is still accepted. That's recorded as _NUMBER_PREFIXED next to the display-prefix table. Non-ASCII digits such as ² are rejected too.
  • Uploads go without an Idempotency-Key. The API rejects the key on non-JSON bodies. Without it a retry could duplicate the upload, so the client retries uploads only after a 429 and reports 5xx and network errors instead. This also fixes statistical-journals add-attachments on main, which has failed this way since 26961bf added the key to every write.
  • statistical-journals add-attachments now uses the shared helper. It gains the size check and rejects folders. 3 and SJ-3 still work.
  • The Multipart type alias moved to client.py and is used in DualEntryClient.post.
  • README workflow example and CHANGELOG entries.

Test plan

  • Unit tests pass (uv run pytest): 357 passed. The 136 skipped are integration tests that need X_API_KEY.
  • Linter passes (uv run ruff check . and uv run ruff format --check .)
  • Manually tested with dualentry <command> against a live test organization; see Artifacts.

tests/test_attachments.py covers: which resources get which commands, the exact multipart request for each resource, several files kept in order, a file exactly at the limit, SJ- handling, and rejection before any request for prefixed IDs, non-ASCII digits, empty, oversized, missing, folder, and no --file.

Artifacts

Run against a test organization on production (api.dualentry.com) with a real API key, using small dummy files.

Found by running it: every upload failed

The client puts an Idempotency-Key on every write, and the API rejects that header on non-JSON bodies (core/idempotency.py: "only supported on JSON requests"). The unit tests mocked the client, so they never saw it. Fixed in 87cde03:

  • Uploads now go without the key.
  • Without a key, a retry could attach the files twice. So uploads are retried only after a 429, which proves the upload didn't run. A 5xx or a network error is reported, not retried.
  • New TestMultipartUploads client tests cover this. Three of the four fail without the fix.

Before (first push of this PR; statistical-journals add-attachments on main fails the same way)

$ dualentry invoices add-attachments 3 --file receipt.txt --file contract.pdf
  ✗ Error: Idempotency-Key is only supported on JSON requests.

Also found: on success the command reprinted the whole invoice, and nothing in the CLI renders attachments, so you couldn't see what was uploaded. It now lists the record's attachments. -o json still prints the full record.

After (this PR)

Invoice 3 had no attachments before this run ("attachments": [] from invoices get 3 -o json).

$ dualentry invoices add-attachments 3 --file receipt.txt --file contract.pdf
(record now has 2 attachments: receipt.txt, contract.pdf, confirmed with `invoices get 3 -o json`)

$ dualentry invoices add-attachments 3 --file receipt.txt
    Attachments on invoice 3
┏━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━┓
┃      ID ┃ File         ┃ Size ┃
┡━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━┩
│ 4848516 │ receipt.txt  │ 20 B │
│ 4848505 │ receipt.txt  │ 20 B │
│ 4848506 │ contract.pdf │ 20 B │
└─────────┴──────────────┴──────┘
3 attachments

$ dualentry invoices replace-attachments 3 --file final.txt
   Attachments on invoice 3
┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━┓
┃      ID ┃ File      ┃ Size ┃
┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━┩
│ 4848517 │ final.txt │ 12 B │
└─────────┴───────────┴──────┘
1 attachment

$ dualentry statistical-journals add-attachments SJ-1 --file receipt.txt
  Attachments on statistical journal 1
│ 4848518 │ receipt.txt │ 20 B │
1 attachment

$ dualentry invoices add-attachments IN-3 --file receipt.txt
│ Invalid value for 'NUMBER': expected a record number from the # column (not the IN-… ID), got 'IN-3' │

$ dualentry invoices add-attachments 3 --file too-big.bin      # 10 MiB + 1 byte
│ Invalid value for '--file': .../too-big.bin is over the 10 MiB limit │

$ dualentry invoices add-attachments 999999 --file receipt.txt
  ✗ Error: Resource not found. Check the ID or number and try again.

Live integration suite

Same result as main: the 4 integration failures already on main (429 without backoff; 403 on vat-rates/product-tax-codes get), and nothing new. One run showed an extra 403 on invoices list; two reruns passed.

Merge note

This PR and #39 both edit the import list of statistical_journals.py, so whichever merges second needs a one-line conflict resolved.

Add `add-attachments NUMBER -f FILE [-f FILE ...]` to every transaction
type whose API accepts file uploads: invoices, bills, sales orders,
purchase orders, journal entries, cash sales, direct expenses, customer
deposits, customer refunds, customer prepayments, customer prepayment
applications, vendor payments, and vendor refunds. Invoices also get
`replace-attachments`, which swaps out every existing attachment.

- Every file is checked against the API's 10 MiB limit, and for being
  empty, before anything is sent, so one bad file cannot leave the
  others half-uploaded. Folders are rejected.
- The record argument is the record number. A prefixed internal ID
  such as IN-42 is rejected instead of being read as number 42, which
  would upload to a different record.
- statistical-journals add-attachments now uses the same code path. It
  keeps accepting SJ-3, since that prefix wraps the record number, and
  gains the size check.
Rich colors its error panels under CI, and the escape codes split a
wrapped message, so substring checks failed there but passed locally.
The API rejects an Idempotency-Key on non-JSON bodies, so every upload
failed with a 400. Uploads now go unkeyed and, since a retry could then
attach the files twice, are only retried after a 429. The command now
lists the record's attachments instead of reprinting the whole record,
which never showed them.
@awoods187

Copy link
Copy Markdown
Author
Screenshot 2026-10-30 at 5 40 28 PM
gh pr checkout 40
Screenshot 2026-10-30 at 5 42 32 PM Screenshot 2026-10-30 at 5 43 20 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant