Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [Unreleased]

- Added `patch` for the twelve resources whose API exposes PATCH, so a partial
JSON file changes only the fields it contains
- `update` now warns which populated fields its PUT would clear, and confirms
when run from a terminal
- `update` warns when omitting `record_status` would post a draft record
- `post` field lists are declared per resource instead of being shared, so the
intercompany-journal-entry shape can no longer be applied to another resource

## [0.1.17] - 2026-04-15


Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ dualentry bills list --status posted --format json

All resources support `list`, `get`, `create`, and `update` operations.

### Changing part of a record

`update` sends PUT, which replaces the record: any field missing from your JSON
file is cleared. To change a few fields and leave the rest alone, use `patch`.

```bash
# Only memo and reference_number change; every other field is untouched.
echo '{"memo": "Q2 true-up", "reference_number": "REF-10"}' > change.json
dualentry invoices patch 1001 --file change.json
```

`patch` is available on the resources whose API exposes it: invoices, bills,
customer payments, customer credits, customer prepayments, customer prepayment
applications, customers, vendors, items, classifications, fixed assets and
contracts. Elsewhere `update` warns before it clears anything.

## Output Formats

```bash
Expand Down
129 changes: 117 additions & 12 deletions src/dualentry_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from __future__ import annotations

import json
import sys
from collections.abc import Callable
from pathlib import Path
from typing import NamedTuple

import typer

from dualentry_cli.cli import HelpfulGroup
from dualentry_cli.client import APIError
from dualentry_cli.output import _RECORD_PREFIX, format_output

# ── Shared option defaults ──────────────────────────────────────────
Expand Down Expand Up @@ -102,17 +105,93 @@ def _load_json_file(file: Path) -> dict:

# ── Post command helpers ───────────────────────────────────────────

_WRITABLE_FIELDS = {"date", "transaction_date", "memo", "currency_iso_4217_code", "exchange_rate", "record_status", "items", "attachments"}
_WRITABLE_ITEM_FIELDS = {"id", "company_id", "account_number", "debit", "credit", "memo", "position", "classifications", "customer_id", "vendor_id", "currency", "eliminate"}

class PostWritableFields(NamedTuple):
"""Fields a resource's `post` may send back. Shapes differ per resource, so each declares its own."""

def _strip_to_writable(data: dict) -> dict:
payload = {k: v for k, v in data.items() if k in _WRITABLE_FIELDS}
if "items" in payload:
payload["items"] = [{k: v for k, v in item.items() if k in _WRITABLE_ITEM_FIELDS} for item in payload["items"]]
record: frozenset[str]
line: frozenset[str] = frozenset()
line_key: str = "items"


def _strip_to_writable(data: dict, writable: PostWritableFields) -> dict:
payload = {k: v for k, v in data.items() if k in writable.record}
lines = payload.get(writable.line_key)
if isinstance(lines, list):
payload[writable.line_key] = [{k: v for k, v in line.items() if k in writable.line} for line in lines]
return payload


# ── Update command helpers ─────────────────────────────────────────

# Assigned or derived by the API: absent from an update file by design, so warning about them would cry wolf.
_SERVER_MANAGED_FIELDS = frozenset(
{
"id",
"internal_id",
"number",
"record_type",
"organization_id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"total",
"subtotal",
"balance",
"amount_due",
"amount_paid",
"tax_total",
"discount_amount",
"net_amount",
}
)


def _is_interactive() -> bool:
"""True when there is a terminal attached to answer a prompt."""
try:
return sys.stdin.isatty()
except (AttributeError, ValueError):
return False


def _holds_data(value) -> bool:
"""True when a field currently carries something a PUT would destroy."""
if value is None:
return False
if isinstance(value, (str, list, dict, tuple)):
return len(value) > 0
return True


def _fields_cleared_by_put(current: dict, payload: dict) -> list[str]:
"""Populated fields on the record that the update file omits."""
return sorted(key for key, value in current.items() if key not in payload and key not in _SERVER_MANAGED_FIELDS and _holds_data(value))


def _warn_before_put(current: dict, payload: dict, *, resource: str, patch_available: bool) -> None:
"""Warn that PUT replaces the record; confirm only when a terminal can answer."""
cleared = _fields_cleared_by_put(current, payload)
posts_draft = current.get("record_status") == "draft" and "record_status" not in payload
if not cleared and not posts_draft:
return

typer.secho(f" ! 'update' sends PUT, which replaces the whole {resource}.", fg=typer.colors.YELLOW, err=True)
if cleared:
typer.secho(" Your file omits these populated fields, so they will be cleared:", fg=typer.colors.YELLOW, err=True)
typer.secho(f" {', '.join(cleared)}", fg=typer.colors.YELLOW, err=True)
if posts_draft:
typer.secho(" record_status is 'draft' and your file omits it, so this will post the record.", fg=typer.colors.YELLOW, err=True)
if patch_available:
typer.secho(" Use 'patch' instead to change only the fields in your file.", fg=typer.colors.YELLOW, err=True)

if not _is_interactive():
return
if not typer.confirm(" Continue?", default=False):
raise typer.Abort


# ── Factory ─────────────────────────────────────────────────────────


Expand All @@ -123,9 +202,10 @@ def make_resource_app(
*,
has_create: bool = True,
has_update: bool = True,
has_patch: bool = False,
has_delete: bool = False,
has_number: bool = False,
has_post: bool = False,
post_writable: PostWritableFields | None = None,
filters: set[str] | None = None,
template: dict | None = None,
checks: list[Callable] | None = None,
Expand Down Expand Up @@ -188,7 +268,6 @@ def get_cmd_auto(
output: str = Format,
):
"""Try by number first, fall back to ID lookup on 404."""
from dualentry_cli.client import APIError
from dualentry_cli.main import get_client

client = get_client()
Expand Down Expand Up @@ -223,7 +302,6 @@ def get_cmd_by_id(
record_id: str = typer.Argument(help="Record ID (e.g. JE-1619031 or 1619031)"),
output: str = Format,
):
from dualentry_cli.client import APIError
from dualentry_cli.main import get_client

client = get_client()
Expand Down Expand Up @@ -271,16 +349,43 @@ def create_cmd(
def update_cmd(
record_id: str = typer.Argument(help="Record ID"),
file: Path = typer.Option(..., "--file", "-f", help="JSON file with update data"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the warning and its confirmation, and the read it needs"),
output: str = Format,
):
from dualentry_cli.main import get_client

payload = _load_json_file(file)
client = get_client()
# The warning costs a read, so --yes skips it outright for batch runs.
current = None
if not yes:
try:
current = client.get(f"/{path}/{record_id}/")
except Exception:
current = None # Advisory only; the PUT reports the real problem.
if current:
_warn_before_put(current, payload, resource=resource, patch_available=has_patch)
data = client.put(f"/{path}/{record_id}/", json=payload)
format_output(data, resource=resource, fmt=output)

update_cmd.__doc__ = f"Update a {resource}."
update_cmd.__doc__ = f"Replace a {resource} (PUT). Fields missing from the file are cleared."

if has_patch:

@app.command("patch")
def patch_cmd(
record_id: str = typer.Argument(help="Record ID"),
file: Path = typer.Option(..., "--file", "-f", help="JSON file with only the fields to change"),
output: str = Format,
):
from dualentry_cli.main import get_client

payload = _load_json_file(file)
client = get_client()
data = client.patch(f"/{path}/{record_id}/", json=payload)
format_output(data, resource=resource, fmt=output)

patch_cmd.__doc__ = f"Update only the supplied fields of a {resource}. Anything not in the file is left alone."

if has_delete:

Expand Down Expand Up @@ -325,7 +430,7 @@ def validate_cmd(

validate_cmd.__doc__ = f"Validate a {resource} payload."

if has_post:
if post_writable is not None:

@app.command("post")
def post_cmd(
Expand All @@ -343,7 +448,7 @@ def post_cmd(
typer.secho(f" \u2717 Cannot post: record is '{current_status}', only draft records can be posted.", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)

payload = _strip_to_writable(data)
payload = _strip_to_writable(data, post_writable)
payload["record_status"] = "posted"
result = client.put(f"/{path}/{stripped}/", json=payload)
format_output(result, resource=resource, fmt=output)
Expand Down
9 changes: 9 additions & 0 deletions src/dualentry_cli/commands/ije_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from decimal import Decimal, InvalidOperation

from dualentry_cli.commands import PostWritableFields

IJE_TEMPLATE = {
"date": "2026-01-01",
"memo": "Intercompany transfer",
Expand Down Expand Up @@ -114,3 +116,10 @@ def check_company_access(payload: dict, client=None) -> list[str]:

IJE_CHECKS = IJE_OFFLINE_CHECKS
IJE_ONLINE_EXTRA_CHECKS = IJE_ONLINE_CHECKS

# Fields the IJE write schema accepts; the read response also carries derived ones it rejects.
IJE_POST_WRITABLE = PostWritableFields(
record=frozenset({"date", "transaction_date", "memo", "currency_iso_4217_code", "exchange_rate", "record_status", "items", "attachments"}),
line=frozenset({"id", "company_id", "account_number", "debit", "credit", "memo", "position", "classifications", "customer_id", "vendor_id", "currency", "eliminate"}),
line_key="items",
)
35 changes: 21 additions & 14 deletions src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +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.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE
from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_POST_WRITABLE, IJE_TEMPLATE
from dualentry_cli.config import Config

app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup)
Expand All @@ -18,19 +18,26 @@
app.add_typer(config_app, name="config")

# Custom-formatted resources (use factory - output.py handles formatting via resource name)
app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_number=True, filters={"customer", "company"}), name="invoices")
app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True, filters={"vendor", "company"}), name="bills")
app.add_typer(make_resource_app("invoices", "invoice", "invoices", has_patch=True, has_number=True, filters={"customer", "company"}), name="invoices")
app.add_typer(make_resource_app("bills", "bill", "bills", has_patch=True, has_number=True, filters={"vendor", "company"}), name="bills")
app.add_typer(accounts_app, name="accounts") # Accounts has custom filtering (no status/date filters)

# Money-in
app.add_typer(make_resource_app("sales orders", "sales-order", "sales-orders", has_number=True, filters={"customer", "company"}), name="sales-orders")
app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True, filters={"customer", "company"}), name="customer-payments")
app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True, filters={"customer", "company"}), name="customer-credits")
app.add_typer(
make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True, filters={"customer", "company"}), name="customer-prepayments"
make_resource_app("customer payments", "customer-payment", "customer-payments", has_patch=True, has_number=True, filters={"customer", "company"}), name="customer-payments"
)
app.add_typer(
make_resource_app("customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True, filters={"customer", "company"}),
make_resource_app("customer credits", "customer-credit", "customer-credits", has_patch=True, has_number=True, filters={"customer", "company"}), name="customer-credits"
)
app.add_typer(
make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_patch=True, has_number=True, filters={"customer", "company"}),
name="customer-prepayments",
)
app.add_typer(
make_resource_app(
"customer prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_patch=True, has_number=True, filters={"customer", "company"}
),
name="customer-prepayment-applications",
)
app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters={"customer", "company"}), name="customer-deposits")
Expand All @@ -52,15 +59,15 @@
# Accounting
app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True), name="journal-entries")
app.add_typer(make_resource_app("bank transfers", "bank-transfer", "bank-transfers", has_number=True), name="bank-transfers")
app.add_typer(make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_number=True), name="fixed-assets")
app.add_typer(make_resource_app("fixed assets", "fixed-asset", "fixed-assets", has_patch=True, has_number=True), name="fixed-assets")
app.add_typer(make_resource_app("depreciation books", "depreciation-book", "depreciation-books"), name="depreciation-books")

# Entities
app.add_typer(make_resource_app("customers", "customer", "customers"), name="customers")
app.add_typer(make_resource_app("vendors", "vendor", "vendors"), name="vendors")
app.add_typer(make_resource_app("items", "item", "items"), name="items")
app.add_typer(make_resource_app("customers", "customer", "customers", has_patch=True), name="customers")
app.add_typer(make_resource_app("vendors", "vendor", "vendors", has_patch=True), name="vendors")
app.add_typer(make_resource_app("items", "item", "items", has_patch=True), name="items")
app.add_typer(make_resource_app("companies", "company", "companies"), name="companies")
app.add_typer(make_resource_app("classifications", "classification", "classifications"), name="classifications")
app.add_typer(make_resource_app("classifications", "classification", "classifications", has_patch=True), name="classifications")

# Recurring
recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup)
Expand All @@ -70,7 +77,7 @@
app.add_typer(recurring_app, name="recurring")

# Other
app.add_typer(make_resource_app("contracts", "contract", "contracts"), name="contracts")
app.add_typer(make_resource_app("contracts", "contract", "contracts", has_patch=True), name="contracts")
app.add_typer(make_resource_app("budgets", "budget", "budgets"), name="budgets")
app.add_typer(make_resource_app("workflows", "workflow", "workflows", has_create=False, has_update=False), name="workflows")
app.add_typer(
Expand All @@ -79,7 +86,7 @@
"intercompany-journal-entry",
"intercompany-journal-entries",
has_number=True,
has_post=True,
post_writable=IJE_POST_WRITABLE,
filters={"company"},
template=IJE_TEMPLATE,
checks=IJE_CHECKS,
Expand Down
Loading
Loading