diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 69c3ad5..d0a95d3 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -100,6 +100,10 @@ def _build_filter_params( "company_id": "--company", "customer_id": "--customer", "vendor_id": "--vendor", + "transaction_type": "--transaction-type", + "record_type": "--record-type", + "min_amount": "--min-amount", + "max_amount": "--max-amount", } @@ -108,8 +112,11 @@ def _resume_all_command(path: str, next_offset: int, filters: dict) -> str: parts = ["dualentry", *path.split("/"), "list", "--all", "--offset", str(next_offset)] for key, flag in _FILTER_CLI_FLAGS.items(): value = filters.get(key) - if value is not None: - parts.extend([flag, str(value)]) + if value is None: + continue + # Repeatable filters arrive as a list; repeat the flag instead of printing the list. + for one in value if isinstance(value, list) else [value]: + parts.extend([flag, str(one)]) return " ".join(parts) diff --git a/src/dualentry_cli/commands/inbox.py b/src/dualentry_cli/commands/inbox.py new file mode 100644 index 0000000..77faa1f --- /dev/null +++ b/src/dualentry_cli/commands/inbox.py @@ -0,0 +1,134 @@ +""" +Inbox commands. + +The inbox splits into two sub-resources with their own list and detail routes. +Both detail routes take a required type discriminator (`transaction_type` / +`record_type`) as a query parameter, which the generic resource factory has no +way to send, so these commands are wired up by hand. +""" + +from __future__ import annotations + +import typer + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import AllPages, EndDate, Format, Limit, Offset, Search, StartDate, _do_list +from dualentry_cli.output import format_output + +transactions_app = typer.Typer(help="Manage transactions awaiting approval", no_args_is_help=True, cls=HelpfulGroup) +records_app = typer.Typer(help="Manage non-monetary records awaiting approval", no_args_is_help=True, cls=HelpfulGroup) + + +def _many(values: list | None) -> list | None: + """Return a repeatable option's values, or None when it was not passed.""" + return list(values) if values else None + + +def _show_detail(data: dict, resource: str, output: str) -> None: + """Print a detail record; say so plainly when the record is not in the inbox.""" + # The API answers with an empty object rather than a 404 when a record has + # no approval workflow attached, which prints as an empty table otherwise. + if not data and output != "json": + typer.echo("This record is not in the inbox.") + return + format_output(data, resource=resource, fmt=output) + + +@transactions_app.command("list") +def list_transactions( + *, + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + transaction_type: list[str] | None = typer.Option(None, "--transaction-type", help="Filter by transaction type (repeatable)"), + status: list[str] | None = typer.Option(None, "--status", help="Filter by approval status (repeatable)"), + company: list[int] | None = typer.Option(None, "--company", "-c", help="Filter by company ID (repeatable)"), + customer: list[int] | None = typer.Option(None, "--customer", help="Filter by customer ID (repeatable)"), + start_date: str | None = StartDate, + end_date: str | None = EndDate, + min_amount: str | None = typer.Option(None, "--min-amount", help="Only transactions at or above this amount"), + max_amount: str | None = typer.Option(None, "--max-amount", help="Only transactions at or below this amount"), + output: str = Format, +): + """List transactions awaiting approval.""" + from dualentry_cli.main import get_client + + client = get_client() + _do_list( + client, + "inbox/transactions", + "inbox-transaction", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + search=search, + status=_many(status), + start_date=start_date, + end_date=end_date, + status_param="approval_status", + transaction_type=_many(transaction_type), + company_id=_many(company), + customer_id=_many(customer), + min_amount=min_amount, + max_amount=max_amount, + ) + + +@transactions_app.command("get") +def get_transaction( + record_id: int = typer.Argument(help="Record ID of the transaction"), + transaction_type: str = typer.Option(..., "--transaction-type", help="Transaction type of that record, e.g. invoice"), + output: str = Format, +): + """Get the approval details of one transaction.""" + from dualentry_cli.main import get_client + + client = get_client() + data = client.get(f"/inbox/transactions/{record_id}/", params={"transaction_type": transaction_type}) + _show_detail(data, "inbox-transaction", output) + + +@records_app.command("list") +def list_records( + *, + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + record_type: list[str] | None = typer.Option(None, "--record-type", help="Filter by record type (repeatable)"), + status: list[str] | None = typer.Option(None, "--status", help="Filter by approval status (repeatable)"), + output: str = Format, +): + """List non-monetary records awaiting approval.""" + from dualentry_cli.main import get_client + + client = get_client() + _do_list( + client, + "inbox/records", + "inbox-record", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + search=search, + status=_many(status), + status_param="approval_status", + record_type=_many(record_type), + ) + + +@records_app.command("get") +def get_record( + record_id: int = typer.Argument(help="Record ID of the customer or vendor"), + record_type: str = typer.Option(..., "--record-type", help="Record type of that record, e.g. customer"), + output: str = Format, +): + """Get the approval details of one non-monetary record.""" + from dualentry_cli.main import get_client + + client = get_client() + data = client.get(f"/inbox/records/{record_id}/", params={"record_type": record_type}) + _show_detail(data, "inbox-record", output) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 5576943..dc7ad7f 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -9,6 +9,8 @@ from dualentry_cli.commands import make_resource_app from dualentry_cli.commands.accounts import app as accounts_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE +from dualentry_cli.commands.inbox import records_app as inbox_records_app +from dualentry_cli.commands.inbox import transactions_app as inbox_transactions_app from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -104,7 +106,11 @@ name="intercompany-journal-entries", ) app.add_typer(make_resource_app("paper checks", "paper-check", "paper-checks", has_create=False, has_update=False, filters=TXN_ALL_PARTIES), name="paper-checks") -app.add_typer(make_resource_app("inbox items", "inbox-item", "inbox", has_get=False, has_create=False, has_update=False, filters={"search"}), name="inbox") +inbox_app = make_resource_app("inbox items", "inbox-item", "inbox", has_get=False, has_create=False, has_update=False, filters={"search"}) +# The two sub-resources carry the actual pending items; `inbox list` is the org-wide count summary. +inbox_app.add_typer(inbox_transactions_app, name="transactions") +inbox_app.add_typer(inbox_records_app, name="records") +app.add_typer(inbox_app, name="inbox") def version_callback(value: bool): diff --git a/tests/test_commands.py b/tests/test_commands.py index e7abf22..d52d148 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -251,3 +251,74 @@ def test_complete_all_does_not_warn(self, mock_get_client): assert result.exit_code == 0 assert "Warning" not in result.output assert "stopped at the" not in result.output + + +class TestInboxCommands: + def test_inbox_list_still_hits_the_summary_route(self, mock_get_client): + mock_get_client.get.return_value = {"transactions_count": 3, "records_count": 1} + result = runner.invoke(app, ["inbox", "list"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/inbox/", params={"limit": 20, "offset": 0}) + + def test_transactions_list(self, mock_get_client): + mock_get_client.get.return_value = {"items": [{"record_id": 42, "number": "IN-1", "approval_status": "pending"}], "count": 1} + result = runner.invoke(app, ["inbox", "transactions", "list"]) + assert result.exit_code == 0 + assert "pending" in result.output + mock_get_client.get.assert_called_once_with("/inbox/transactions/", params={"limit": 20, "offset": 0}) + + def test_transactions_list_filters(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke( + app, + ["inbox", "transactions", "list", "--transaction-type", "invoice", "--transaction-type", "bill", "--status", "pending", "--min-amount", "500"], + ) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with( + "/inbox/transactions/", + params={"approval_status": ["pending"], "transaction_type": ["invoice", "bill"], "min_amount": "500", "limit": 20, "offset": 0}, + ) + + def test_transactions_get_sends_the_type_discriminator(self, mock_get_client): + mock_get_client.get.return_value = {"record_id": 42, "transaction_type": "invoice", "approval_status": "pending"} + result = runner.invoke(app, ["inbox", "transactions", "get", "42", "--transaction-type", "invoice"]) + assert result.exit_code == 0 + assert "pending" in result.output + mock_get_client.get.assert_called_once_with("/inbox/transactions/42/", params={"transaction_type": "invoice"}) + + def test_transactions_get_requires_the_type_discriminator(self, mock_get_client): + result = runner.invoke(app, ["inbox", "transactions", "get", "42"]) + assert result.exit_code == 2 + mock_get_client.get.assert_not_called() + + def test_get_says_so_when_the_record_is_not_in_the_inbox(self, mock_get_client): + mock_get_client.get.return_value = {} + result = runner.invoke(app, ["inbox", "transactions", "get", "42", "--transaction-type", "invoice"]) + assert result.exit_code == 0 + assert "not in the inbox" in result.output + + def test_get_json_output_stays_json_when_absent(self, mock_get_client): + mock_get_client.get.return_value = {} + result = runner.invoke(app, ["inbox", "records", "get", "7", "--record-type", "customer", "--format", "json"]) + assert result.exit_code == 0 + output_lines = result.output.strip().split("\n") + json_start = next(i for i, line in enumerate(output_lines) if line.strip().startswith("{")) + assert json.loads("\n".join(output_lines[json_start:])) == {} + + def test_records_list(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["inbox", "records", "list", "--record-type", "customer"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/inbox/records/", params={"record_type": ["customer"], "limit": 20, "offset": 0}) + + def test_records_get_sends_the_type_discriminator(self, mock_get_client): + mock_get_client.get.return_value = {"record_id": 7, "record_type": "customer", "approval_status": "approved"} + result = runner.invoke(app, ["inbox", "records", "get", "7", "--record-type", "customer"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/inbox/records/7/", params={"record_type": "customer"}) + + def test_resume_command_repeats_a_list_filter(self): + from dualentry_cli.commands import _resume_all_command + + cmd = _resume_all_command("inbox/transactions", 300, {"transaction_type": ["invoice", "bill"]}) + assert cmd == "dualentry inbox transactions list --all --offset 300 --transaction-type invoice --transaction-type bill"