diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe986b..552a334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index 88a8dba..e5df5c1 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -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: diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 69c3ad5..0431191 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -21,6 +21,7 @@ "company": ("--company", "-c"), "customer": ("--customer",), "vendor": ("--vendor",), + "adjusting_entry": ("--adjusting-entry",), } ALL_FILTERS = frozenset(FILTER_OPTIONS) @@ -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, @@ -100,6 +112,7 @@ def _build_filter_params( "company_id": "--company", "customer_id": "--customer", "vendor_id": "--vendor", + "is_adjusting_entry": "--adjusting-entry", } @@ -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 @@ -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}." diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 66f0cb9..43239d9 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -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( diff --git a/tests/test_client.py b/tests/test_client.py index aa552fa..2a86d9a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 diff --git a/tests/test_filter_gating.py b/tests/test_filter_gating.py index 9aaa82e..3495f80 100644 --- a/tests/test_filter_gating.py +++ b/tests/test_filter_gating.py @@ -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(), @@ -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: @@ -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"