Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ ignore = [
"PLR0915",
"F841",
"SIM105",
"PLR0917",
Comment thread
GustavoCaso marked this conversation as resolved.

# 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.
Expand Down
42 changes: 42 additions & 0 deletions src/dualentry_cli/commands/actions.py
Original file line number Diff line number Diff line change
@@ -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)


Comment thread
GustavoCaso marked this conversation as resolved.
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"]
250 changes: 250 additions & 0 deletions src/dualentry_cli/commands/bank_match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
"""Bank-match commands."""
Comment thread
GustavoCaso marked this conversation as resolved.

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,
):
"""
Comment thread
GustavoCaso marked this conversation as resolved.
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)
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_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 @@ -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):
Expand Down
Loading
Loading