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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- `journal-entries list --adjusting-entry true|false` returns only adjusting entries, or only regular ones
- Validation errors name the API `error_code` when the API returns one, e.g. `adjusting_entry_permission_required`
- 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
7 changes: 6 additions & 1 deletion src/dualentry_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,17 @@ def _handle_response(self, response: httpx.Response, *, sent_idempotency_key: bo
if status == 404:
raise APIError(404, "Resource not found. Check the ID or number and try again.")
if status == 422:
code = None
try:
detail = response.json()
errors = detail.get("errors", detail)
code = detail.get("error_code")
except Exception:
errors = response.text
raise APIError(422, f"Validation error: {errors}")
# Coded refusals (period_locked, adjusting_entry_bank_account, ...) carry a stable
# error_code that scripts branch on; the messages alone are for people.
label = f"Validation error [{code}]" if code else "Validation error"
raise APIError(422, f"{label}: {errors}")
if status == 409:
detail = _server_detail(response)
if _RETRY_AFTER_HEADER in response.headers:
Expand Down
21 changes: 21 additions & 0 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"company": ("--company", "-c"),
"customer": ("--customer",),
"vendor": ("--vendor",),
"adjusting_entry": ("--adjusting-entry",),
}
ALL_FILTERS = frozenset(FILTER_OPTIONS)

Expand Down Expand Up @@ -69,6 +70,17 @@ def _supplied(value) -> str | None:
return value if isinstance(value, str) else None


def _true_or_false(value: str | None) -> str | None:
"""Normalize a true/false filter value; None leaves the filter off."""
if value is None:
return None
normalized = value.strip().lower()
if normalized not in {"true", "false"}:
msg = f"expected true or false, got {value!r}"
raise typer.BadParameter(msg)
return normalized


def _build_filter_params(
search: str | None = None,
status: str | None = None,
Expand Down Expand Up @@ -100,6 +112,7 @@ def _build_filter_params(
"company_id": "--company",
"customer_id": "--customer",
"vendor_id": "--vendor",
"is_adjusting_entry": "--adjusting-entry",
}


Expand Down Expand Up @@ -226,6 +239,13 @@ def list_cmd(
company: str | None = typer.Option(None, "--company", "-c", help="Filter by company ID"),
customer: str | None = typer.Option(None, "--customer", help="Filter by customer ID"),
vendor: str | None = typer.Option(None, "--vendor", help="Filter by vendor ID"),
adjusting_entry: str | None = typer.Option(
None,
"--adjusting-entry",
metavar="true|false",
callback=_true_or_false,
help="true: only adjusting entries; false: only regular ones. Omit for both.",
),
output: str = Format,
):
from dualentry_cli.main import get_client
Expand All @@ -247,6 +267,7 @@ def list_cmd(
company_id=_supplied(company),
customer_id=_supplied(customer),
vendor_id=_supplied(vendor),
is_adjusting_entry=_supplied(adjusting_entry),
)

list_cmd.__doc__ = f"List {name}."
Expand Down
5 changes: 4 additions & 1 deletion src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@
app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=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, filters=TXN_ALL_PARTIES | {"adjusting_entry"}),
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
17 changes: 17 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ def test_422_shows_validation_details(self):
assert "validation" in exc.value.detail.lower()
assert "customer_id" in exc.value.detail

@respx.mock
def test_422_names_the_error_code(self):
from dualentry_cli.client import APIError, DualEntryClient

body = {
"success": False,
"errors": {"is_adjusting_entry": ["You do not have permission to post adjusting entries."]},
"error_code": "adjusting_entry_permission_required",
"error_reason": "You do not have the permission to post adjusting entries.",
}
respx.post("https://api.dualentry.com/public/v2/journal-entries/").mock(return_value=httpx.Response(422, json=body))
client = DualEntryClient(api_url="https://api.dualentry.com", api_key="test_key")
with pytest.raises(APIError) as exc:
client.post("/journal-entries/", json={"is_adjusting_entry": True})
assert "[adjusting_entry_permission_required]" in exc.value.detail
assert "You do not have permission to post adjusting entries." in exc.value.detail

@respx.mock
def test_429_says_rate_limited(self):
from dualentry_cli.client import APIError, DualEntryClient
Expand Down
41 changes: 39 additions & 2 deletions tests/test_filter_gating.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"vendor-prepayment-applications": TXN_VENDOR,
"vendor-refunds": TXN_VENDOR,
"direct-expenses": TXN_VENDOR,
"journal-entries": TXN_ALL_PARTIES,
"journal-entries": TXN_ALL_PARTIES | {"adjusting-entry"},
"bank-transfers": TXN,
"fixed-assets": {"search", "status", "company", "customer", "vendor"},
"depreciation-books": set(),
Expand All @@ -57,7 +57,7 @@
"inbox": {"search"},
}

FILTER_FLAGS = {"search", "status", "start-date", "end-date", "company", "customer", "vendor"}
FILTER_FLAGS = {"search", "status", "start-date", "end-date", "company", "customer", "vendor", "adjusting-entry"}


def _plain(text: str) -> str:
Expand Down Expand Up @@ -186,3 +186,40 @@ def test_filters_argument_is_required(self):

with pytest.raises(TypeError):
make_resource_app("widgets", "widget", "widgets")


class TestAdjustingEntryFilter:
@pytest.mark.parametrize(("value", "sent"), [("true", "true"), ("false", "false"), ("TRUE", "true"), ("False", "false")])
def test_value_maps_to_is_adjusting_entry(self, mock_get_client, value, sent):
result = runner.invoke(app, ["journal-entries", "list", "--adjusting-entry", value])
assert result.exit_code == 0, result.output
mock_get_client.get.assert_called_once_with("/journal-entries/", params={"is_adjusting_entry": sent, "limit": 20, "offset": 0})

def test_omitted_sends_no_filter(self, mock_get_client):
result = runner.invoke(app, ["journal-entries", "list"])
assert result.exit_code == 0, result.output
mock_get_client.get.assert_called_once_with("/journal-entries/", params={"limit": 20, "offset": 0})

def test_other_values_are_a_usage_error(self, mock_get_client):
result = runner.invoke(app, ["journal-entries", "list", "--adjusting-entry", "yes"])
assert result.exit_code == 2
assert "expected true or false" in _plain(result.output)
mock_get_client.get.assert_not_called()

@pytest.mark.parametrize("path", ["invoices", "intercompany-journal-entries", "recurring/journal-entries"])
def test_other_resources_reject_it(self, mock_get_client, path):
result = runner.invoke(app, [*path.split("/"), "list", "--adjusting-entry", "true"])
assert result.exit_code != 0
assert "does not support --adjusting-entry" in _plain(result.output)
mock_get_client.get.assert_not_called()

def test_help_lists_the_flag(self):
result = runner.invoke(app, ["journal-entries", "list", "--help"])
assert result.exit_code == 0
assert "--adjusting-entry" in _plain(result.output)

def test_all_resume_hint_keeps_the_filter(self):
from dualentry_cli.commands import _resume_all_command

cmd = _resume_all_command("journal-entries", 100000, {"is_adjusting_entry": "true"})
assert cmd == "dualentry journal-entries list --all --offset 100000 --adjusting-entry true"
Loading