Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ build/
.eggs/
*.spec
.idea
.tokensave
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Comment thread
andremanuelbarbosa marked this conversation as resolved.

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

Expand Down
192 changes: 192 additions & 0 deletions src/dualentry_cli/commands/bank_connections.py
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")
Comment thread
andremanuelbarbosa marked this conversation as resolved.
def delete_connection(
Comment thread
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)
2 changes: 2 additions & 0 deletions src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
156 changes: 156 additions & 0 deletions tests/test_bank_connections.py
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
Loading