Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

- Add `add-attachments` to the 13 transaction types whose API accepts file uploads, and `replace-attachments` to invoices. Files are checked against the 10 MiB limit before anything is sent
- Fix attachment uploads failing with "Idempotency-Key is only supported on JSON requests". Uploads are sent without the key and retried only after a 429, so a retry cannot attach files twice
- `statistical-journals add-attachments` now checks the 10 MiB limit before uploading, rejects folders, and rejects prefixes other than `SJ-`
- Line-item tables label the quantity column Quantity
- Update the CLI greeting logo to DualEntry
- Add `export-jobs` commands (`create`, `list`, `get`, `wait`) for on-demand destination data refreshes via the public API
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ dualentry invoices create --file invoice.json
dualentry journal-entries list --start-date 2025-01-01 --end-date 2025-03-31 --format json
```

### Attach a receipt to a bill

```bash
# Pass the record number from the # column; repeat -f for several files (10 MiB max each)
dualentry bills add-attachments 1042 -f receipt.pdf -f approval.eml

# Invoices can also swap out every attachment at once
dualentry invoices replace-attachments 318 -f signed-invoice.pdf
```

### Automate in CI/CD

Set `X_API_KEY` in your environment for non-interactive authentication:
Expand Down Expand Up @@ -97,6 +107,7 @@ dualentry export-jobs wait 42 --interval 10 --timeout 1800

Most resources support `list`, `get`, `create`, and `update`; some (e.g. Bank Connections, Bank Match) expose a different verb set — see `--help`.
Export jobs support `create`, `list`, `get`, and `wait` (poll until finished).
Transactions that accept files support `add-attachments` (see `dualentry <resource> --help`); invoices also support `replace-attachments`.

## Output Formats

Expand Down
20 changes: 15 additions & 5 deletions src/dualentry_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
import time
import uuid
from typing import Any
from typing import Any, TypeAlias

import httpx

Expand All @@ -33,6 +33,10 @@

_MAX_RETRY_AFTER = 60

# One `("files", (filename, content))` part per upload. Bytes, not open files: a
# retried request resends these parts, and a file handle would already be spent.
Multipart: TypeAlias = list[tuple[str, tuple[str, bytes]]]

# Hard ceiling for --all crawls: page_size 100 x 1000 pages = 100_000 items.
# Truncation must warn; never report the truncated length as the API total.
_MAX_PAGES = 1000
Expand Down Expand Up @@ -173,7 +177,11 @@ def _handle_response(self, response: httpx.Response, *, sent_idempotency_key: bo

def _request(self, method: str, path: str, **kwargs) -> dict:
method = method.upper()
keyed = method in _IDEMPOTENCY_METHODS
# The API rejects an Idempotency-Key on non-JSON bodies, so multipart uploads
# go unkeyed. Without a key, only a 429 proves the write did not run; a network
# error or 5xx may follow a completed upload, so those are not retried.
unkeyed_write = method in _IDEMPOTENCY_METHODS and "files" in kwargs
keyed = method in _IDEMPOTENCY_METHODS and not unkeyed_write
if keyed:
# One key per logical request, deliberately generated here rather than
# per attempt: reusing it across retries is what makes a retry safe.
Expand All @@ -190,13 +198,15 @@ def _request(self, method: str, path: str, **kwargs) -> dict:
retry_after = None
try:
response = self._client.request(method, path, **kwargs)
if not _is_retryable(response):
retryable = response.status_code == 429 if unkeyed_write else _is_retryable(response)
if not retryable:
return self._handle_response(response, sent_idempotency_key=keyed)
retry_after = _retry_after_seconds(response)
if retry_after is not None and retry_after > _MAX_RETRY_AFTER:
return self._handle_response(response, sent_idempotency_key=keyed)
except _RETRYABLE_EXCEPTIONS:
pass
if unkeyed_write:
raise

# every retry waits, including the one after the loop
delay = retry_after if retry_after is not None else backoff
Expand Down Expand Up @@ -254,7 +264,7 @@ def post(
path: str,
json: dict[str, Any] | None = None,
*,
files: list[tuple[str, tuple[str, bytes]]] | None = None,
files: Multipart | None = None,
) -> dict:
if files is not None:
return self._request("POST", path, files=files)
Expand Down
173 changes: 170 additions & 3 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
from __future__ import annotations

import json
from collections.abc import Callable
from collections.abc import Callable, Sequence
from pathlib import Path

import typer

from dualentry_cli.cli import HelpfulGroup, make_list_command_cls
from dualentry_cli.client import _MAX_PAGES
from dualentry_cli.output import _RECORD_PREFIX, format_output
from dualentry_cli.client import _MAX_PAGES, Multipart
from dualentry_cli.output import _NUMBER_PREFIXED, _RECORD_PREFIX, format_output, print_attachments

# Filter flags `list` can offer, with every option spelling each one accepts.
FILTER_OPTIONS = {
Expand Down Expand Up @@ -175,6 +175,159 @@ def _strip_to_writable(data: dict) -> dict:
return payload


# ── Attachment helpers ─────────────────────────────────────────────

# The API rejects empty files and files over 10 MiB, checking each file in turn and
# storing the ones before it first. Checking every file up front fails before any
# bytes are sent, so a bad last file can't leave the earlier ones half-uploaded.
ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
_ATTACHMENT_MAX_LABEL = f"{ATTACHMENT_MAX_BYTES // 2**20} MiB"

AttachmentFiles = typer.Option(
...,
"--file",
"-f",
help=f"File to attach (max {_ATTACHMENT_MAX_LABEL}); repeat the flag for several",
exists=True,
dir_okay=False,
readable=True,
)


def _record_number_arg(value: str, resource: str) -> str:
"""
Return the record number to put in an attachment URL.

Args:
value: What the user typed: the record number from the `#` column, or for
resources in `_NUMBER_PREFIXED`, that number with its prefix (`SJ-3`).
resource: The resource name, which decides what its display prefix wraps.

Returns:
The record number as ASCII digits.

Raises:
typer.BadParameter: If the value is not a record number. For most resources a
prefixed value such as `IN-42` is the internal ID, not the number, so
stripping the prefix would upload to whichever record has number 42.

"""
prefix = _RECORD_PREFIX.get(resource)
number_prefixed = resource in _NUMBER_PREFIXED
if number_prefixed and prefix:
head, sep, rest = value.partition("-")
if sep and head.upper() == prefix:
value = rest
# isascii(): str.isdigit() also accepts "²" and "٤٢", which no record URL matches.
if value.isascii() and value.isdigit():
return value
raise typer.BadParameter(f"expected a record number{_record_number_hint(resource)}, got {value!r}", param_hint="'NUMBER'")


def _record_number_hint(resource: str) -> str:
"""Say which form of record number `resource` takes, for the error message."""
prefix = _RECORD_PREFIX.get(resource)
if not prefix:
return " from the # column"
if resource in _NUMBER_PREFIXED:
return f" such as 3 or {prefix}-3"
return f" from the # column (not the {prefix}-… ID)"


def _read_attachment(path: Path) -> bytes:
"""
Read one file for upload, refusing it if the API would.

The read stops one byte past the limit, so an oversized file is never loaded
whole, and the size is judged on the bytes actually read, not an earlier stat()
that the file could outgrow before the upload.

Args:
path: A path given with `--file`.

Returns:
The file's content.

Raises:
typer.BadParameter: If the file is empty or larger than `ATTACHMENT_MAX_BYTES`.

"""
with path.open("rb") as fh:
content = fh.read(ATTACHMENT_MAX_BYTES + 1)
if not content:
raise typer.BadParameter(f"{path} is empty", param_hint="'--file'")
if len(content) > ATTACHMENT_MAX_BYTES:
raise typer.BadParameter(f"{path} is over the {_ATTACHMENT_MAX_LABEL} limit", param_hint="'--file'")
return content


def _read_attachments(files: Sequence[Path]) -> Multipart:
"""
Read every file for upload, so one bad file stops the upload before it starts.

Args:
files: The paths given with `--file`, in order.

Returns:
Multipart parts for `DualEntryClient.post(files=...)`.

Raises:
typer.BadParameter: If any file is empty or larger than `ATTACHMENT_MAX_BYTES`.

"""
contents = [_read_attachment(path) for path in files]
return [("files", (path.name, content)) for path, content in zip(files, contents, strict=True)]


def _add_attachment_commands(app: typer.Typer, path: str, resource: str, *, replace: bool) -> None:
"""
Register `add-attachments`, and `replace-attachments` when the API offers it.

Args:
app: The resource's Typer app.
path: The API path, e.g. `"invoices"`.
resource: The resource name, for output formatting and record-number parsing.
replace: Whether the resource has `POST /{number}/attachments/replace/`.

"""
prefix = _RECORD_PREFIX.get(resource)
number_help = f"Record number (the # column), e.g. 3 or {prefix}-3" if resource in _NUMBER_PREFIXED and prefix else "Record number (the # column)"

def upload(number: str, files: Sequence[Path], output: str, *, replace_existing: bool) -> None:
from dualentry_cli.main import get_client

record_number = _record_number_arg(number, resource)
multipart = _read_attachments(files)
route = "attachments/replace/" if replace_existing else "attachments/"
data = get_client().post(f"/{path}/{record_number}/{route}", files=multipart)
if output == "json":
format_output(data, resource=resource, fmt=output)
else:
print_attachments(data, label=f"{resource.replace('-', ' ')} {record_number}")

@app.command("add-attachments")
def add_attachments_cmd(
number: str = typer.Argument(help=number_help),
file: list[Path] = AttachmentFiles,
output: str = Format,
) -> None:
upload(number, file, output, replace_existing=False)

add_attachments_cmd.__doc__ = "Attach file(s) to a record, keeping its existing attachments."

if replace:

@app.command("replace-attachments")
def replace_attachments_cmd(
number: str = typer.Argument(help=number_help),
file: list[Path] = AttachmentFiles,
output: str = Format,
) -> None:
upload(number, file, output, replace_existing=True)

replace_attachments_cmd.__doc__ = "Replace all attachments on a record. Its current attachments are deleted."


# ── Factory ─────────────────────────────────────────────────────────


Expand All @@ -189,6 +342,8 @@ def make_resource_app(
has_delete: bool = False,
has_number: bool = False,
has_post: bool = False,
has_attachments: bool = False,
has_replace_attachments: bool = False,
filters: set[str],
status_param: str = "record_status",
template: dict | None = None,
Expand All @@ -200,6 +355,9 @@ def make_resource_app(

`filters` lists the flags this resource's v2 list endpoint declares; the rest
are removed from `list` so the API cannot silently drop them.

`has_attachments` and `has_replace_attachments` mirror the v2 routes
`POST /{number}/attachments/` and `POST /{number}/attachments/replace/`.
"""
import inspect

Expand All @@ -208,6 +366,12 @@ def make_resource_app(
if unknown:
msg = f"{name}: unknown filter flags {sorted(unknown)}"
raise ValueError(msg)
if has_replace_attachments and not has_attachments:
msg = f"{name}: has_replace_attachments requires has_attachments"
raise ValueError(msg)
if has_attachments and not has_number:
msg = f"{name}: has_attachments requires has_number (attachment routes take the record number)"
raise ValueError(msg)
remove = ALL_FILTERS - filters

gated_options = {option for flag in remove for option in FILTER_OPTIONS[flag]}
Expand Down Expand Up @@ -426,6 +590,9 @@ def post_cmd(

post_cmd.__doc__ = f"Post a draft {resource}."

if has_attachments:
_add_attachment_commands(app, path, resource, replace=has_replace_attachments)

if template is not None:

@app.command("template")
Expand Down
16 changes: 2 additions & 14 deletions src/dualentry_cli/commands/statistical_journals.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Offset,
Search,
Status,
_add_attachment_commands,
_load_json_file,
_strip_record_prefix,
)
Expand Down Expand Up @@ -150,17 +151,4 @@ def update_cmd(
format_output(data, resource="statistical-journal", fmt=output)


@app.command("add-attachments")
def add_attachments_cmd(
number: str = typer.Argument(help="Record number of the journal"),
file: list[Path] = typer.Option(..., "--file", "-f", help="Attachment file(s) to upload", exists=True, readable=True),
output: str = Format,
):
"""Upload attachment file(s) to a statistical journal."""
from dualentry_cli.main import get_client

client = get_client()
stripped = _strip_record_prefix(number)
multipart = [("files", (path.name, path.read_bytes())) for path in file]
data = client.post(f"/statistical-journals/{stripped}/attachments/", files=multipart)
format_output(data, resource="statistical-journal", fmt=output)
_add_attachment_commands(app, "statistical-journals", "statistical-journal", replace=False)
33 changes: 20 additions & 13 deletions src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,37 +29,44 @@
TXN_ALL_PARTIES = TXN | {"customer", "vendor"}

# Custom-formatted resources (use factory - output.py handles formatting via resource name)
app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, filters=TXN_CUSTOMER), name="invoices")
app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, filters=TXN_VENDOR), name="bills")
app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, has_attachments=True, has_replace_attachments=True, filters=TXN_CUSTOMER), name="invoices")
app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="bills")
app.add_typer(accounts_app, name="accounts") # Accounts has custom filtering (no status/date filters)

# Money-in
app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, filters=TXN_CUSTOMER), name="sales-orders")
app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="sales-orders")
app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters=TXN_CUSTOMER), name="customer-payments")
app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters=TXN_CUSTOMER), name="customer-credits")
app.add_typer(make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, filters=TXN_CUSTOMER), name="customer-prepayments")
app.add_typer(
make_resource_app("customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, filters=TXN_CUSTOMER),
make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, has_attachments=True, filters=TXN_CUSTOMER),
name="customer-prepayments",
)
app.add_typer(
make_resource_app(
"customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, has_attachments=True, filters=TXN_CUSTOMER
),
name="customer-prepayment-applications",
)
app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters=TXN_CUSTOMER), name="customer-deposits")
app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, filters=TXN_CUSTOMER), name="customer-refunds")
app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, filters=TXN_CUSTOMER), name="cash-sales")
app.add_typer(
make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="customer-deposits"
)
app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="customer-refunds")
app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, has_attachments=True, filters=TXN_CUSTOMER), name="cash-sales")

# Money-out
app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, filters=TXN_VENDOR), name="purchase-orders")
app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, filters=TXN_VENDOR), name="vendor-payments")
app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="purchase-orders")
app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="vendor-payments")
app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True, filters=TXN_VENDOR), name="vendor-credits")
app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True, filters=TXN_VENDOR), name="vendor-prepayments")
app.add_typer(
make_resource_app("vendor prepayment applications", "vendor-prepayment-application", "vendor-prepayment-applications", has_number=True, filters=TXN_VENDOR),
name="vendor-prepayment-applications",
)
app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, filters=TXN_VENDOR), name="vendor-refunds")
app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, filters=TXN_VENDOR), name="direct-expenses")
app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="vendor-refunds")
app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, has_attachments=True, filters=TXN_VENDOR), name="direct-expenses")

# Accounting
app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True, filters=TXN_ALL_PARTIES), name="journal-entries")
app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True, has_attachments=True, filters=TXN_ALL_PARTIES), name="journal-entries")
app.add_typer(statistical_journals_app, name="statistical-journals")
app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True, filters=TXN), name="bank-transfers")
app.add_typer(
Expand Down
Loading
Loading