From c31c0d2079ff92535dfaa08bbfa31e7618cbca4c Mon Sep 17 00:00:00 2001 From: Andre Barbosa Date: Fri, 25 Sep 2026 12:18:14 +0100 Subject: [PATCH] ONB-2261-feat(bank-connections): add bank-connections commands for customer API feeds --- .gitignore | 1 + README.md | 3 +- .../commands/bank_connections.py | 192 ++++++++++++++++++ src/dualentry_cli/main.py | 2 + tests/test_bank_connections.py | 156 ++++++++++++++ 5 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/dualentry_cli/commands/bank_connections.py create mode 100644 tests/test_bank_connections.py diff --git a/.gitignore b/.gitignore index 750abae..8edc6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .eggs/ *.spec .idea +.tokensave diff --git a/README.md b/README.md index b6a4d4e..8673236 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,9 @@ dualentry bills list --status posted --format json | **Master Data** | Customers, Vendors, Items, Accounts, Classifications | | **Automation** | Recurring Invoices, Recurring Bills, Workflows, Contracts | | **Close Management** | Bank Match | +| **Bank Feeds** | Bank Connections | -All resources support `list`, `get`, `create`, and `update` operations. +Most resources support `list`, `get`, `create`, and `update`; some (e.g. Bank Connections, Bank Match) expose a different verb set — see `--help`. ## Output Formats diff --git a/src/dualentry_cli/commands/bank_connections.py b/src/dualentry_cli/commands/bank_connections.py new file mode 100644 index 0000000..56d98db --- /dev/null +++ b/src/dualentry_cli/commands/bank_connections.py @@ -0,0 +1,192 @@ +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 +from dualentry_cli.output import format_output + +app = make_action_app("Manage bank connections") +accounts_app = make_action_app("Manage accounts under a bank connection") +transactions_app = make_action_app("Push bank transactions for a registered account") +app.add_typer(accounts_app, name="accounts") +app.add_typer(transactions_app, name="transactions") + + +@app.command("list") +def list_connections( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + updated_after: datetime | None = typer.Option( + None, + "--updated-after", + help="Only connections updated at or after this time (ISO 8601)", + ), + updated_before: datetime | None = typer.Option( + None, + "--updated-before", + help="Only connections updated at or before this time (ISO 8601)", + ), + output: str = Format, +): + """List bank connections.""" + run_list( + "bank-connections", + resource="bank-connection", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + updated_after=updated_after.isoformat() if updated_after else None, + updated_before=updated_before.isoformat() if updated_before else None, + ) + + +@app.command("get") +def get_connection( + connection_id: int = typer.Argument(help="DualEntry bank connection ID"), + output: str = Format, +): + """Get one bank connection by ID.""" + run_get(f"/bank-connections/{connection_id}/", resource="bank-connection", output=output) + + +@app.command("create") +def create_connection( + file: Path = typer.Option(..., "--file", "-f", help="JSON file with connection registration body"), + output: str = Format, +): + """Register a bank connection (and optional accounts).""" + body = load_json_file(file) + run_post("/bank-connections/", resource="bank-connection", output=output, body=body) + + +@app.command("delete") +def delete_connection( + connection_id: int = typer.Argument(help="DualEntry bank connection ID to unregister"), +): + """Unregister a customer API bank connection.""" + from dualentry_cli.main import get_client + + get_client().delete(f"/bank-connections/{connection_id}/") + typer.echo(f"Bank connection {connection_id} deleted.") + + +@accounts_app.command("list") +def list_accounts( + connection_id: int = typer.Argument(help="DualEntry bank connection ID"), + output: str = Format, +): + """List accounts registered under a bank connection.""" + from dualentry_cli.main import get_client + + data = get_client().get(f"/bank-connections/{connection_id}/accounts/") + if isinstance(data, list): + data = {"items": data, "count": len(data)} + format_output(data, resource="bank-connection-account", fmt=output) + + +@accounts_app.command("create") +def create_accounts( + connection_id: int = typer.Argument(help="DualEntry bank connection ID"), + file: Path = typer.Option(..., "--file", "-f", help="JSON file with accounts array body"), + output: str = Format, +): + """Register accounts under an existing bank connection.""" + body = load_json_file(file) + run_post( + f"/bank-connections/{connection_id}/accounts/", + resource="bank-connection", + output=output, + body=body, + ) + + +@transactions_app.command("push") +def push_transactions( + financial_account_id: int = typer.Argument(help="DualEntry financial account ID"), + file: Path = typer.Option(..., "--file", "-f", help="JSON file with transactions batch body"), + output: str = Format, +): + """Push a batch of bank transactions for a registered account.""" + body = load_json_file(file) + run_post( + f"/bank-connections/accounts/{financial_account_id}/transactions/", + resource="bank-connection", + output=output, + body=body, + ) + + +_TEMPLATE_CONNECTION = { + "connection_source_id": "conn-1", + "institution_name": "Customer Bank", + "accounts": [ + { + "account_id": "acct-checking", + "account_name": "Checking", + "truncated_account_number": "1234", + }, + { + "account_id": "acct-savings", + "account_name": "Savings", + "currency_iso_4217_code": "USD", + }, + ], +} +_TEMPLATE_ACCOUNTS = { + "accounts": [ + { + "account_id": "acct-checking", + "account_name": "Checking", + "truncated_account_number": "1234", + } + ] +} +_TEMPLATE_TRANSACTIONS = { + "transactions": [ + { + "external_trx_id": "tx-1", + "account_id": "acct-checking", + "date": "2026-01-15T00:00:00", + "amount": "10.00", + "description": "Deposit", + "is_posted": True, + "posted_at": "2026-01-15T00:00:00", + "counterparty": "Example Merchant", + } + ] +} + + +@app.command("template") +def template_cmd( + output_file: Path | None = typer.Option(None, "--output", "-o", help="Write template to file instead of stdout"), + template_type: str = typer.Option( + "connection", + "--type", + "-t", + help='Template type: "connection", "accounts", or "transactions"', + ), +): + """Output a sample bank-connections JSON template.""" + if template_type == "connection": + template = _TEMPLATE_CONNECTION + elif template_type == "accounts": + template = _TEMPLATE_ACCOUNTS + elif template_type == "transactions": + template = _TEMPLATE_TRANSACTIONS + else: + raise typer.BadParameter(f"Unknown template type: {template_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) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 5036fd0..6ef4842 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_connections import app as bank_connections_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 @@ -107,6 +108,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") +app.add_typer(bank_connections_app, name="bank-connections") def version_callback(value: bool): diff --git a/tests/test_bank_connections.py b/tests/test_bank_connections.py new file mode 100644 index 0000000..3e36fd6 --- /dev/null +++ b/tests/test_bank_connections.py @@ -0,0 +1,156 @@ +import json +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from dualentry_cli.main import app + +runner = CliRunner() + + +def test_list_connections(): + client = MagicMock() + client.get.return_value = {"items": [], "count": 0} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke( + app, + ["bank-connections", "list", "--updated-after", "2026-01-01T00:00:00"], + ) + assert result.exit_code == 0 + client.get.assert_called_once_with( + "/bank-connections/", + params={"limit": 20, "offset": 0, "updated_after": "2026-01-01T00:00:00"}, + ) + + +def test_get_connection(): + client = MagicMock() + client.get.return_value = {"id": 42, "connection_source_id": "conn-1"} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke(app, ["bank-connections", "get", "42"]) + assert result.exit_code == 0 + client.get.assert_called_once_with("/bank-connections/42/", params=None) + + +def test_create_connection(tmp_path): + payload = { + "connection_source_id": "conn-1", + "institution_name": "Customer Bank", + "accounts": [{"account_id": "acct-1", "account_name": "Checking"}], + } + file = tmp_path / "connection.json" + file.write_text(json.dumps(payload)) + client = MagicMock() + client.post.return_value = {"id": 1, **payload} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke(app, ["bank-connections", "create", "--file", str(file)]) + assert result.exit_code == 0 + client.post.assert_called_once_with("/bank-connections/", json=payload) + + +def test_create_requires_file(): + with patch("dualentry_cli.main.get_client", return_value=MagicMock()): + result = runner.invoke(app, ["bank-connections", "create"]) + assert result.exit_code == 2 + + +def test_delete_connection(): + client = MagicMock() + client.delete.return_value = {"success": True, "errors": {}} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke(app, ["bank-connections", "delete", "42"]) + assert result.exit_code == 0 + client.delete.assert_called_once_with("/bank-connections/42/") + assert "Bank connection 42 deleted." in result.output + + +def test_accounts_list(): + client = MagicMock() + client.get.return_value = [{"id": 1, "account_id": "acct-1", "account_name": "Checking"}] + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke(app, ["bank-connections", "accounts", "list", "42", "--format", "json"]) + assert result.exit_code == 0 + client.get.assert_called_once_with("/bank-connections/42/accounts/") + parsed = json.loads(result.output) + assert parsed["count"] == 1 + assert parsed["items"][0]["account_id"] == "acct-1" + + +def test_accounts_create(tmp_path): + payload = {"accounts": [{"account_id": "acct-2", "account_name": "Savings"}]} + file = tmp_path / "accounts.json" + file.write_text(json.dumps(payload)) + client = MagicMock() + client.post.return_value = {"id": 42, "accounts": payload["accounts"]} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke( + app, + ["bank-connections", "accounts", "create", "42", "--file", str(file)], + ) + assert result.exit_code == 0 + client.post.assert_called_once_with("/bank-connections/42/accounts/", json=payload) + + +def test_transactions_push(tmp_path): + payload = { + "transactions": [ + { + "external_trx_id": "tx-1", + "account_id": "acct-1", + "date": "2026-01-15T00:00:00", + "amount": "10.00", + "description": "Deposit", + "is_posted": True, + } + ] + } + file = tmp_path / "transactions.json" + file.write_text(json.dumps(payload)) + client = MagicMock() + client.post.return_value = {"success": True, "results": [{"external_trx_id": "tx-1", "status": "created"}]} + with patch("dualentry_cli.main.get_client", return_value=client): + result = runner.invoke( + app, + ["bank-connections", "transactions", "push", "99", "--file", str(file)], + ) + assert result.exit_code == 0 + client.post.assert_called_once_with( + "/bank-connections/accounts/99/transactions/", + json=payload, + ) + + +def test_template_connection_stdout(): + result = runner.invoke(app, ["bank-connections", "template", "--type", "connection"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["connection_source_id"] == "conn-1" + assert parsed["institution_name"] == "Customer Bank" + assert isinstance(parsed["accounts"], list) + + +def test_template_accounts_to_file(tmp_path): + out_file = tmp_path / "accounts.json" + result = runner.invoke( + app, + ["bank-connections", "template", "--type", "accounts", "--output", str(out_file)], + ) + assert result.exit_code == 0 + assert out_file.exists() + parsed = json.loads(out_file.read_text()) + assert "accounts" in parsed + assert parsed["accounts"][0]["account_id"] == "acct-checking" + + +def test_template_transactions_stdout(): + result = runner.invoke(app, ["bank-connections", "template", "--type", "transactions"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert "transactions" in parsed + assert parsed["transactions"][0]["external_trx_id"] == "tx-1" + + +def test_template_unknown_type(): + result = runner.invoke(app, ["bank-connections", "template", "--type", "nope"]) + assert result.exit_code == 2 + assert "Unknown template type" in result.output