Skip to content
Draft
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
11 changes: 9 additions & 2 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand All @@ -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)


Expand Down
134 changes: 134 additions & 0 deletions src/dualentry_cli/commands/inbox.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 7 additions & 1 deletion src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
71 changes: 71 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"