diff --git a/src/dualentry_cli/client.py b/src/dualentry_cli/client.py index da16788..88a8dba 100644 --- a/src/dualentry_cli/client.py +++ b/src/dualentry_cli/client.py @@ -249,7 +249,15 @@ def paginate( result["next_offset"] = start_offset + len(all_items) return result - def post(self, path: str, json: dict[str, Any] | None = None) -> dict: + def post( + self, + path: str, + json: dict[str, Any] | None = None, + *, + files: list[tuple[str, tuple[str, bytes]]] | None = None, + ) -> dict: + if files is not None: + return self._request("POST", path, files=files) return self._request("POST", path, json=json) def put(self, path: str, json: dict[str, Any] | None = None) -> dict: diff --git a/src/dualentry_cli/commands/statistical_journals.py b/src/dualentry_cli/commands/statistical_journals.py new file mode 100644 index 0000000..664c4d0 --- /dev/null +++ b/src/dualentry_cli/commands/statistical_journals.py @@ -0,0 +1,166 @@ +"""Statistical journal commands (custom list filters and attachment upload).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import ( + AllPages, + Format, + Limit, + Offset, + Search, + Status, + _load_json_file, + _strip_record_prefix, +) +from dualentry_cli.commands.actions import run_list +from dualentry_cli.output import format_output + +app = typer.Typer(help="Manage statistical journals", no_args_is_help=True, cls=HelpfulGroup) + + +def _parse_csv_ints(value: str) -> list[int] | None: + """Parse a comma-separated list of integers for list filters.""" + raw = value.strip() + if not raw: + return None + result: list[int] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + try: + result.append(int(token)) + except ValueError: + raise typer.BadParameter(f"expected comma-separated integers, got {token!r}") from None + return result or None + + +def _csv_ints_option(flag: str, *, help: str) -> typer.Option: + return typer.Option(flag, parser=_parse_csv_ints, help=help) + + +def _csv_strings(value: str | None) -> list[str] | None: + if not value: + return None + return [part.strip() for part in value.split(",") if part.strip()] + + +@app.command("list") +def list_cmd( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + search: str | None = Search, + status: str | None = Status, + company: str | None = typer.Option(None, "--company", "-c", help="Filter by company ID"), + date_start: str | None = typer.Option(None, "--date-start", help="Filter from journal date (YYYY-MM-DD)"), + date_end: str | None = typer.Option(None, "--date-end", help="Filter to journal date (YYYY-MM-DD)"), + period_start: str | None = typer.Option(None, "--period-start", help="Filter from period start (YYYY-MM-DD)"), + period_end: str | None = typer.Option(None, "--period-end", help="Filter to period end (YYYY-MM-DD)"), + number: Annotated[ + list[int] | None, + _csv_ints_option("--number", help="Filter by record number(s), comma-separated"), + ] = None, + account_id: Annotated[ + list[int] | None, + _csv_ints_option("--account-id", help="Filter by account ID(s), comma-separated"), + ] = None, + account_number: str | None = typer.Option(None, "--account-number", help="Filter by account number(s), comma-separated"), + line_account_id: Annotated[ + list[int] | None, + _csv_ints_option("--line-account-id", help="Filter by line account ID(s), comma-separated"), + ] = None, + line_account_number: str | None = typer.Option(None, "--line-account-number", help="Filter by line account number(s), comma-separated"), + updated_after: str | None = typer.Option(None, "--updated-after", help="Updated after timestamp (ISO 8601)"), + updated_before: str | None = typer.Option(None, "--updated-before", help="Updated before timestamp (ISO 8601)"), + ordering: str | None = typer.Option(None, "--ordering", help="Sort order (API field name, optional leading -)"), + output: str = Format, +): + """List statistical journals.""" + run_list( + "statistical-journals", + resource="statistical-journal", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + search=search, + status=status, + company_id=company or None, + date_start=date_start or None, + date_end=date_end or None, + period_start=period_start or None, + period_end=period_end or None, + number=number, + account_id=account_id, + account_number=_csv_strings(account_number), + line_account_id=line_account_id, + line_account_number=_csv_strings(line_account_number), + updated_after=updated_after or None, + updated_before=updated_before or None, + ordering=ordering or None, + ) + + +@app.command("get") +def get_cmd( + value: str = typer.Argument(help="Record number (#) or prefixed number (e.g. SJ-3)"), + output: str = Format, +): + """Get a statistical journal by number.""" + from dualentry_cli.main import get_client + + client = get_client() + data = client.get(f"/statistical-journals/{_strip_record_prefix(value)}/") + format_output(data, resource="statistical-journal", fmt=output) + + +@app.command("create") +def create_cmd( + file: Path = typer.Option(..., "--file", "-f", help="JSON file with record data"), + output: str = Format, +): + """Create a statistical journal from a JSON file.""" + from dualentry_cli.main import get_client + + payload = _load_json_file(file) + client = get_client() + data = client.post("/statistical-journals/", json=payload) + format_output(data, resource="statistical-journal", fmt=output) + + +@app.command("update") +def update_cmd( + number: str = typer.Argument(help="Record number of the journal to update"), + file: Path = typer.Option(..., "--file", "-f", help="JSON file with update data"), + output: str = Format, +): + """Update a statistical journal.""" + from dualentry_cli.main import get_client + + payload = _load_json_file(file) + client = get_client() + data = client.put(f"/statistical-journals/{_strip_record_prefix(number)}/", json=payload) + 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) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index d043937..66f0cb9 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -12,6 +12,7 @@ from dualentry_cli.commands.bank_match import app as bank_match_app from dualentry_cli.commands.export_jobs import app as export_jobs_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE +from dualentry_cli.commands.statistical_journals import app as statistical_journals_app from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -59,6 +60,7 @@ # 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(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( make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True, filters={"search", "status", "company", "customer", "vendor"}), @@ -72,6 +74,12 @@ app.add_typer(make_resource_app("items", "item", "items", filters={"search", "status"}), name="items") app.add_typer(make_resource_app("companies", "company", "companies", has_create=False, has_update=False, filters={"search"}), name="companies") app.add_typer(make_resource_app("classifications", "classification", "classifications", filters={"search"}), name="classifications") +app.add_typer(make_resource_app("custom fields", "custom-field", "custom-fields", has_delete=True, filters={"search"}), name="custom-fields") +app.add_typer(make_resource_app("product tax codes", "product-tax-code", "product-tax-codes", has_delete=True, filters={"search"}), name="product-tax-codes") +app.add_typer( + make_resource_app("VAT rates", "vat-rate", "vat-rates", has_create=False, has_update=False, filters=set()), + name="vat-rates", +) # Recurring recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup) diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 60b44f0..50e707d 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -34,6 +34,7 @@ "intercompany-journal-entry": "IJE", "bank-transfer": "BT", "fixed-asset": "FA", + "statistical-journal": "SJ", } @@ -1192,6 +1193,179 @@ def _workflow_detail(r): _register("workflow", _workflow_list, _workflow_detail) +# ── Custom field ───────────────────────────────────────────────────── + + +def _custom_field_list(items): + table = Table(title="Custom Fields", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Name", min_width=20, style="bold") + table.add_column("Type") + table.add_column("Active") + + for r in items: + table.add_row( + str(r.get("id", "")), + r.get("name", "-"), + str(r.get("field_type", "-")), + "yes" if r.get("is_active") else "no", + ) + + console.print(table) + + +def _custom_field_detail(r): + header = Text() + header.append("CUSTOM FIELD", style="bold") + header.append(f" {r.get('name', '')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r.get("id", "-"))) + details.add_row("Name:", r.get("name", "-")) + details.add_row("Type:", str(r.get("field_type", "-"))) + details.add_row("Active:", "yes" if r.get("is_active") else "no") + console.print(details) + + +_register("custom-field", _custom_field_list, _custom_field_detail) + + +# ── Product tax code ───────────────────────────────────────────────── + + +def _product_tax_code_list(items): + table = Table(title="Product Tax Codes", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Code", style="bold") + table.add_column("Description", min_width=24) + table.add_column("Active") + + for r in items: + table.add_row( + str(r.get("id", "")), + r.get("code", "-"), + (r.get("description") or "-")[:40], + "yes" if r.get("is_active") else "no", + ) + + console.print(table) + + +def _product_tax_code_detail(r): + header = Text() + header.append("PRODUCT TAX CODE", style="bold") + header.append(f" {r.get('code', '')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r.get("id", "-"))) + details.add_row("Code:", r.get("code", "-")) + details.add_row("Description:", r.get("description", "-")) + details.add_row("Source:", str(r.get("source", "-"))) + details.add_row("Active:", "yes" if r.get("is_active") else "no") + console.print(details) + + +_register("product-tax-code", _product_tax_code_list, _product_tax_code_detail) + + +# ── VAT rate ───────────────────────────────────────────────────────── + + +def _vat_rate_list(items): + table = Table(title="VAT Rates", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Country") + table.add_column("Name", min_width=20, style="bold") + table.add_column("Rate", justify="right") + table.add_column("Active") + + for r in items: + table.add_row( + str(r.get("id", "")), + r.get("country_code", "-"), + r.get("name", "-"), + str(r.get("rate", "-")), + "yes" if r.get("is_active") else "no", + ) + + console.print(table) + + +def _vat_rate_detail(r): + header = Text() + header.append("VAT RATE", style="bold") + header.append(f" {r.get('name', '')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r.get("id", "-"))) + details.add_row("Country:", r.get("country_code", "-")) + details.add_row("Name:", r.get("name", "-")) + details.add_row("Rate:", str(r.get("rate", "-"))) + details.add_row("Type:", str(r.get("rate_type", "-"))) + details.add_row("Tax type:", str(r.get("tax_type", "-"))) + details.add_row("Valid from:", str(r.get("valid_from", "-"))) + details.add_row("Valid to:", str(r.get("valid_to", "-"))) + console.print(details) + + +_register("vat-rate", _vat_rate_list, _vat_rate_detail) + + +# ── Statistical journal ────────────────────────────────────────────── + + +def _statistical_journal_list(items): + table = Table(title="Statistical Journals", show_lines=False) + table.add_column("#", style="dim", justify="right") + table.add_column("Date") + table.add_column("Period") + table.add_column("Account") + table.add_column("Status") + table.add_column("Memo", min_width=20) + + for r in items: + table.add_row( + _fmt_id(r.get("number"), "statistical-journal"), + str(r.get("date", "-")), + str(r.get("period", "-")), + r.get("account_number", "-"), + _status_badge(r.get("record_status", "")), + (r.get("memo") or "-")[:40], + ) + + console.print(table) + + +def _statistical_journal_detail(r): + header = Text() + header.append("STATISTICAL JOURNAL", style="bold") + header.append(f" {_fmt_id(r.get('number'), 'statistical-journal')}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("Number:", _fmt_id(r.get("number"), "statistical-journal")) + details.add_row("Date:", str(r.get("date", "-"))) + details.add_row("Period:", str(r.get("period", "-"))) + details.add_row("Status:", _status_badge(r.get("record_status", ""))) + details.add_row("Account:", f"{r.get('account_number', '-')} {r.get('account_name', '')}".strip()) + details.add_row("Memo:", r.get("memo", "-")) + console.print(details) + + +_register("statistical-journal", _statistical_journal_list, _statistical_journal_detail) + + # ── Export jobs (destination data refresh) ─────────────────────────── diff --git a/tests/test_commands.py b/tests/test_commands.py index e7abf22..df51c56 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -184,6 +184,74 @@ def test_create_with_missing_file(self, tmp_path): assert "not found" in result.output.lower() or "Error" in result.output +class TestParityResourceCommands: + def test_product_tax_codes_crud_paths(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["product-tax-codes", "list"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_with("/product-tax-codes/", params={"limit": 20, "offset": 0}) + + def test_custom_fields_delete(self, mock_get_client): + result = runner.invoke(app, ["custom-fields", "delete", "9"]) + assert result.exit_code == 0 + mock_get_client.delete.assert_called_once_with("/custom-fields/9/") + + def test_vat_rates_read_only(self): + from dualentry_cli.main import app as cli_app + + group = next(g for g in cli_app.registered_groups if g.name == "vat-rates") + commands = {c.name for c in group.typer_instance.registered_commands} + assert commands == {"list", "get"} + + def test_statistical_journal_date_filters_use_api_names(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke( + app, + [ + "statistical-journals", + "list", + "--date-start", + "2025-01-01", + "--date-end", + "2025-03-31", + "--period-start", + "2025-01-01", + "--period-end", + "2025-03-31", + ], + ) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with( + "/statistical-journals/", + params={ + "date_start": "2025-01-01", + "date_end": "2025-03-31", + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "limit": 20, + "offset": 0, + }, + ) + + def test_statistical_journal_add_attachments(self, mock_get_client, tmp_path): + mock_get_client.post.return_value = {"number": 1} + upload = tmp_path / "note.txt" + upload.write_text("hello") + result = runner.invoke(app, ["statistical-journals", "add-attachments", "3", "--file", str(upload)]) + assert result.exit_code == 0 + mock_get_client.post.assert_called_once() + args, kwargs = mock_get_client.post.call_args + assert args[0] == "/statistical-journals/3/attachments/" + assert kwargs["files"][0][0] == "files" + assert kwargs["files"][0][1][0] == "note.txt" + + def test_statistical_journal_list_rejects_non_integer_csv(self): + result = runner.invoke(app, ["statistical-journals", "list", "--number", "1,abc"]) + assert result.exit_code == 2 + assert "comma-separated integers" in result.output + assert "abc" in result.output + + class TestUnknownCommandSuggestions: """HelpfulGroup must catch typer's UsageError, not click's (see typer >= 0.26).""" diff --git a/tests/test_filter_gating.py b/tests/test_filter_gating.py index d3973c7..9aaa82e 100644 --- a/tests/test_filter_gating.py +++ b/tests/test_filter_gating.py @@ -43,6 +43,9 @@ "items": {"search", "status"}, "companies": {"search"}, "classifications": {"search"}, + "custom-fields": {"search"}, + "product-tax-codes": {"search"}, + "vat-rates": set(), "recurring/invoices": {"search", "company"}, "recurring/bills": {"search", "company"}, "recurring/journal-entries": {"search", "company"}, diff --git a/tests/test_integration.py b/tests/test_integration.py index ca602a1..a829cf7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -71,6 +71,7 @@ def assert_record(data: dict, required_keys: list[str]): ("journal-entries", ["number", "date", "currency_iso_4217_code"]), ("bank-transfers", ["number", "date"]), ("fixed-assets", ["number", "name"]), + ("statistical-journals", ["number", "date"]), ] @@ -126,6 +127,9 @@ def test_numbered_resource_offset(client: DualEntryClient, path: str, detail_key ("contracts", "id", ["id", "name"]), ("budgets", "id", ["id", "name"]), ("workflows", "id", ["id", "name"]), + ("custom-fields", "id", ["id", "name"]), + ("product-tax-codes", "id", ["id", "code"]), + ("vat-rates", "id", ["id", "name"]), ] diff --git a/tests/test_stale_commands.py b/tests/test_stale_commands.py index f3fbf39..9493fa0 100644 --- a/tests/test_stale_commands.py +++ b/tests/test_stale_commands.py @@ -32,6 +32,10 @@ def test_read_only_resource_keeps_its_read_commands(resource: str): assert _commands(resource) == {"list", "get"} +def test_vat_rates_is_read_only(): + assert _commands("vat-rates") == {"list", "get"} + + def test_inbox_keeps_only_list(): assert _commands("inbox") == {"list"}