diff --git a/README.md b/README.md index 853b248..b6a4d4e 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ dualentry bills list --status posted --format json | **Accounting** | Journal Entries, Bank Transfers, Fixed Assets, Depreciation | | **Master Data** | Customers, Vendors, Items, Accounts, Classifications | | **Automation** | Recurring Invoices, Recurring Bills, Workflows, Contracts | +| **Close Management** | Bank Match | All resources support `list`, `get`, `create`, and `update` operations. diff --git a/pyproject.toml b/pyproject.toml index ff98171..d961871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,7 @@ ignore = [ "PLR0915", "F841", "SIM105", + "PLR0917", # Added to ALL in ruff 0.16.0. This project declares no license and ships no # LICENSE file, so there is no copyright header to require. diff --git a/src/dualentry_cli/commands/actions.py b/src/dualentry_cli/commands/actions.py new file mode 100644 index 0000000..1601be0 --- /dev/null +++ b/src/dualentry_cli/commands/actions.py @@ -0,0 +1,42 @@ +"""Helpers for custom API action commands.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import Format, _do_list, _load_json_file +from dualentry_cli.output import format_output + + +def make_action_app(help: str) -> typer.Typer: + return typer.Typer(help=help, no_args_is_help=True, cls=HelpfulGroup) + + +def run_get(path: str, *, resource: str, output: str, params: dict | None = None) -> None: + from dualentry_cli.main import get_client + + data = get_client().get(path, params=params) + format_output(data, resource=resource, fmt=output) + + +def run_list(path: str, *, resource: str, limit: int, offset: int, all_pages: bool, output: str, **filters) -> None: + from dualentry_cli.main import get_client + + _do_list(get_client(), path.strip("/"), resource, limit=limit, offset=offset, all_pages=all_pages, output=output, **filters) + + +def run_post(path: str, *, resource: str, output: str, body: dict | None = None) -> None: + from dualentry_cli.main import get_client + + data = get_client().post(path, json=body) + format_output(data, resource=resource, fmt=output) + + +def load_json_file(file: Path) -> dict: + return _load_json_file(file) + + +__all__ = ["Format", "load_json_file", "make_action_app", "run_get", "run_list", "run_post"] diff --git a/src/dualentry_cli/commands/bank_match.py b/src/dualentry_cli/commands/bank_match.py new file mode 100644 index 0000000..af53dfb --- /dev/null +++ b/src/dualentry_cli/commands/bank_match.py @@ -0,0 +1,250 @@ +"""Bank-match commands.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +import typer + +from dualentry_cli.commands import AllPages, Format, Limit, Offset +from dualentry_cli.commands.actions import load_json_file, make_action_app, run_get, run_list, run_post + +app = make_action_app("Manage bank matches") +suggestions_app = make_action_app("Manage bank-match suggestions") +transactions_app = make_action_app("Manage bank transactions") +app.add_typer(suggestions_app, name="suggestions") +app.add_typer(transactions_app, name="bank-transactions") + + +@app.command("status-counts") +def status_counts(output: str = Format): + """ + Show bank-match processing counts. + + See how many bank rows sit in each pipeline stage. + Work is still running while unprocessed, awaiting_ai, or ai_in_progress + is non-zero. + """ + run_get("/bank-match/status-counts/", resource="bank-match", output=output) + + +@suggestions_app.command("list") +def list_suggestions( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + financial_transaction_id: int | None = typer.Option( + None, + "--financial-transaction-id", + help="Limit to candidates for one bank-feed row ID", + ), + suggestion_type: str | None = typer.Option( + None, + "--suggestion-type", + help="Suggestion kind: match (existing transaction) or create (draft new)", + ), + highest_ranked: bool = typer.Option( + False, + "--highest-ranked", + help="Return only the top pick per bank row (same default as the UI)", + ), + output: str = Format, +): + """ + List bank-match suggestions. + + Each suggestion pairs a bank-feed row with a DualEntry transaction. + """ + run_list( + "bank-match/suggestions", + resource="bank-match-suggestion", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + financial_transaction_id=financial_transaction_id, + suggestion_type=suggestion_type, + is_highest_ranked=True if highest_ranked else None, + ) + + +@suggestions_app.command("get") +def get_suggestion( + suggestion_id: int = typer.Argument(help="Suggestion ID from suggestions list"), + output: str = Format, +): + """Get one bank-match suggestion by ID.""" + run_get(f"/bank-match/suggestions/{suggestion_id}/", resource="bank-match-suggestion", output=output) + + +@transactions_app.command("list") +def list_bank_transactions( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + financial_account_id: int | None = typer.Option( + None, + "--financial-account-id", + help="Limit to one connected bank or credit-card account ID", + ), + matching_status: str | None = typer.Option( + None, + "--matching-status", + help="Pipeline stage, e.g. unprocessed, ai_suggested, matched, no_match, excluded", + ), + date_from: datetime | None = typer.Option( + None, + "--date-from", + help="Inclusive lower bound on transaction date (YYYY-MM-DD)", + ), + date_to: datetime | None = typer.Option( + None, + "--date-to", + help="Inclusive upper bound on transaction date (YYYY-MM-DD)", + ), + is_posted: bool | None = typer.Option( + None, + "--is-posted/--not-posted", + help="Posted-only (--is-posted) or pending-only (--not-posted); omit for both", + ), + include_expired: bool = typer.Option( + False, + "--include-expired", + help="Include expired bank-feed rows (hidden by default)", + ), + output: str = Format, +): + """ + List bank-feed transactions. + + Page through bank rows, decide DualEntry targets, then run match. + """ + run_list( + "bank-match/bank-transactions", + resource="bank-transaction", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + financial_account_id=financial_account_id, + matching_status=matching_status, + date_from=date_from.isoformat() if date_from else None, + date_to=date_to.isoformat() if date_to else None, + is_posted=is_posted, + include_expired=True if include_expired else None, + ) + + +@transactions_app.command("get") +def get_bank_transaction( + financial_transaction_id: int = typer.Argument(help="Bank-feed transaction ID from bank-transactions list"), + output: str = Format, +): + """Get one bank-feed transaction by ID.""" + run_get( + f"/bank-match/bank-transactions/{financial_transaction_id}/", + resource="bank-transaction", + output=output, + ) + + +@app.command("match") +def match( + file: Path | None = typer.Option( + None, + "--file", + "-f", + help="JSON file with full match body (supports 1:1 and M:N). Cannot combine with ID flags", + ), + financial_transaction_id: int | None = typer.Option( + None, + "--financial-transaction-id", + help="Bank-feed row ID to match (1:1). Required when --file is omitted", + ), + transaction_id: int | None = typer.Option( + None, + "--transaction-id", + help="DualEntry transaction ID to pair with (1:1). Use this or --entry-id, not both", + ), + entry_id: int | None = typer.Option( + None, + "--entry-id", + help="DualEntry entry ID for a partial match (1:1). Use this or --transaction-id, not both", + ), + output: str = Format, +): + """ + Confirm a bank match. + + Provide either --file with the API JSON body, or a 1:1 match via + --financial-transaction-id plus exactly one of --transaction-id or + --entry-id. + """ + if file: + if any(value is not None for value in (financial_transaction_id, transaction_id, entry_id)): + raise typer.BadParameter("--file cannot be combined with match ID options") + body = load_json_file(file) + else: + if financial_transaction_id is None: + raise typer.BadParameter("provide --file or --financial-transaction-id") + if (transaction_id is None) == (entry_id is None): + raise typer.BadParameter("provide exactly one of --transaction-id or --entry-id") + body = {"financial_transaction_id": financial_transaction_id} + body["transaction_id" if transaction_id is not None else "entry_id"] = transaction_id or entry_id + run_post("/bank-match/matches/", resource="bank-match", output=output, body=body) + + +_TEMPLATE_1_1 = {"financial_transaction_id": 10, "transaction_id": 20} +_TEMPLATE_M_N = {"financial_transaction_ids": [10, 11], "transaction_ids": [20, 21]} +_TEMPLATE_PARTIAL = {"financial_transaction_id": 10, "entry_id": 20} + + +@app.command("template") +def template_cmd( + output_file: Path | None = typer.Option(None, "--output", "-o", help="Write template to file instead of stdout"), + type: str = typer.Option("1:1", "--type", "-t", help='Type of template. valid values are "1:1", "m:n", "partial"'), +): + """Output a sample bank match JSON template.""" + if type == "1:1": + template = _TEMPLATE_1_1 + elif type == "m:n": + template = _TEMPLATE_M_N + elif type == "partial": + template = _TEMPLATE_PARTIAL + else: + raise typer.BadParameter(f"Unknown template type: {type}") + + content = json.dumps(template, indent=2) + if output_file: + output_file.write_text(content + "\n") + typer.secho(f"Template written to {output_file}", fg=typer.colors.GREEN) + else: + typer.echo(content) + + +@app.command("unmatch") +def unmatch( + financial_transaction_id: int | None = typer.Option( + None, + "--financial-transaction-id", + help="Bank-feed row ID in the match group to undo. Provide this or --match-group-id", + ), + match_group_id: int | None = typer.Option( + None, + "--match-group-id", + help="Match group ID from a prior match response. Provide this or --financial-transaction-id", + ), + output: str = Format, +): + """ + Undo a bank match. + + Provide exactly one of --financial-transaction-id or --match-group-id. + Unmatching any member dissolves the whole group. + """ + if (financial_transaction_id is None) == (match_group_id is None): + raise typer.BadParameter("provide exactly one of --financial-transaction-id or --match-group-id") + body = {"financial_transaction_id": financial_transaction_id} if financial_transaction_id is not None else {"match_group_id": match_group_id} + run_post("/bank-match/unmatches/", resource="bank-match", output=output, body=body) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 5576943..5036fd0 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -8,6 +8,7 @@ from dualentry_cli.cli import HelpfulGroup from dualentry_cli.commands import make_resource_app from dualentry_cli.commands.accounts import app as accounts_app +from dualentry_cli.commands.bank_match import app as bank_match_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE from dualentry_cli.config import Config @@ -105,6 +106,7 @@ ) 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") +app.add_typer(bank_match_app, name="bank-match") def version_callback(value: bool): diff --git a/tests/test_bank_match.py b/tests/test_bank_match.py new file mode 100644 index 0000000..1988a43 --- /dev/null +++ b/tests/test_bank_match.py @@ -0,0 +1,77 @@ +import json +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from dualentry_cli.main import app + +runner = CliRunner() + + +def test_suggestions_list(): + client = MagicMock() + client.get.return_value = {"items": [], "count": 0} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke(app, ["bank-match", "suggestions", "list", "--highest-ranked"]) + assert result.exit_code == 0 + client.get.assert_called_once_with( + "/bank-match/suggestions/", + params={"limit": 20, "offset": 0, "is_highest_ranked": True}, + ) + + +def test_bank_transactions_list(): + client = MagicMock() + client.get.return_value = {"items": [], "count": 0} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke(app, ["bank-match", "bank-transactions", "list", "--matching-status", "matched"]) + assert result.exit_code == 0 + client.get.assert_called_once_with( + "/bank-match/bank-transactions/", + params={"limit": 20, "offset": 0, "matching_status": "matched"}, + ) + + +def test_match_one_to_one(): + client = MagicMock() + client.post.return_value = {"success": True, "errors": {}} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke( + app, + ["bank-match", "match", "--financial-transaction-id", "10", "--transaction-id", "20"], + ) + assert result.exit_code == 0 + client.post.assert_called_once_with( + "/bank-match/matches/", + json={"financial_transaction_id": 10, "transaction_id": 20}, + ) + + +def test_unmatch_requires_one_identifier(): + with patch("dualentry_cli.main.get_client", return_value=MagicMock()): + result = runner.invoke(app, ["bank-match", "unmatch"]) + assert result.exit_code == 2 + assert "provide exactly one" in result.output + + +def test_template_stdout(): + result = runner.invoke(app, ["bank-match", "template"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["financial_transaction_id"] == 10 + assert parsed["transaction_id"] == 20 + + +def test_template_to_file(tmp_path): + out_file = tmp_path / "template.json" + result = runner.invoke(app, ["bank-match", "template", "--output", str(out_file)]) + assert result.exit_code == 0 + assert out_file.exists() + parsed = json.loads(out_file.read_text()) + assert parsed["financial_transaction_id"] == 10 + assert parsed["transaction_id"] == 20 + + +def test_bank_match_has_no_crud_list(): + result = runner.invoke(app, ["bank-match", "list"]) + assert result.exit_code != 0