-
Notifications
You must be signed in to change notification settings - Fork 4
ONB-2261-feat(bank-connections): add bank-connections commands for customer API feeds #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
andremanuelbarbosa
wants to merge
1
commit into
main
Choose a base branch
from
andrem/onb-2261-cli-no-commands-for-bank-connections-7-ops
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+353
−1
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,3 +7,4 @@ build/ | |
| .eggs/ | ||
| *.spec | ||
| .idea | ||
| .tokensave | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
|
andremanuelbarbosa marked this conversation as resolved.
|
||
| def delete_connection( | ||
|
andremanuelbarbosa marked this conversation as resolved.
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.