diff --git a/src/dualentry_cli/commands/__init__.py b/src/dualentry_cli/commands/__init__.py index 47ebd4a..ca73815 100644 --- a/src/dualentry_cli/commands/__init__.py +++ b/src/dualentry_cli/commands/__init__.py @@ -56,6 +56,7 @@ def _build_filter_params( status: str | None = None, start_date: str | None = None, end_date: str | None = None, + **extra, ) -> dict: """Build filter query params, omitting None values.""" params: dict = {} @@ -67,6 +68,7 @@ def _build_filter_params( params["start_date"] = start_date if end_date: params["end_date"] = end_date + params.update({key: value for key, value in extra.items() if value is not None}) return params @@ -123,11 +125,16 @@ def make_resource_app( has_delete: bool = False, has_number: bool = False, has_post: bool = False, + filters: set[str] | None = None, template: dict | None = None, - validate_fn: Callable[[Path], None] | None = None, + checks: list[Callable] | None = None, + online_checks: list[Callable] | None = None, ) -> typer.Typer: """Create a Typer app for a standard CRUD resource.""" + import inspect + app = typer.Typer(help=f"Manage {name}", no_args_is_help=True, cls=HelpfulGroup) + enabled_filters = filters or set() @app.command("list") def list_cmd( @@ -138,15 +145,39 @@ def list_cmd( status: str | None = Status, start_date: str | None = StartDate, end_date: str | None = EndDate, + company: str | None = typer.Option(None, "--company", "-c", help="Filter by company ID"), + customer: str | None = typer.Option(None, "--customer", help="Filter by customer ID"), + vendor: str | None = typer.Option(None, "--vendor", help="Filter by vendor ID"), output: str = Format, ): from dualentry_cli.main import get_client client = get_client() - _do_list(client, path, resource, limit, offset, all_pages, output, search=search, status=status, start_date=start_date, end_date=end_date) + _do_list( + client, + path, + resource, + limit, + offset, + all_pages, + output, + search=search, + status=status, + start_date=start_date, + end_date=end_date, + company_id=company if isinstance(company, str) else None, + customer_id=customer if isinstance(customer, str) else None, + vendor_id=vendor if isinstance(vendor, str) else None, + ) list_cmd.__doc__ = f"List {name}." + all_filters = {"company", "customer", "vendor"} + remove = all_filters - enabled_filters + if remove: + sig = inspect.signature(list_cmd) + list_cmd.__signature__ = sig.replace(parameters=[p for p in sig.parameters.values() if p.name not in remove]) + if has_number: @app.command("get") @@ -263,15 +294,34 @@ def delete_cmd( delete_cmd.__doc__ = f"Delete a {resource}." - if validate_fn is not None: + if checks: @app.command("validate") def validate_cmd( file: Path = typer.Option(..., "--file", "-f", help="JSON file to validate"), + online: bool = typer.Option(False, "--online", help="Also run checks that require API access"), ): - validate_fn(file) + payload = _load_json_file(file) + errors: list[str] = [] + client = None + if online: + from dualentry_cli.main import get_client + + client = get_client() + all_checks = list(checks) + if online and online_checks: + all_checks.extend(online_checks) + for check in all_checks: + if errors: + break + errors.extend(check(payload, client=client)) + if errors: + for err in errors: + typer.secho(f" \u2717 {err}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + typer.secho(" \u2713 Valid", fg=typer.colors.GREEN) - validate_cmd.__doc__ = f"Validate a {resource} payload (client-side)." + validate_cmd.__doc__ = f"Validate a {resource} payload." if has_post: diff --git a/src/dualentry_cli/commands/ije_extras.py b/src/dualentry_cli/commands/ije_extras.py index 623aa90..9be74e9 100644 --- a/src/dualentry_cli/commands/ije_extras.py +++ b/src/dualentry_cli/commands/ije_extras.py @@ -2,11 +2,7 @@ from __future__ import annotations -from pathlib import Path - -import typer - -from dualentry_cli.commands import _load_json_file +from decimal import Decimal, InvalidOperation IJE_TEMPLATE = { "date": "2026-01-01", @@ -55,47 +51,66 @@ } -def validate_ije(file: Path) -> None: - from decimal import Decimal, InvalidOperation +# ── Checks ───────────────────────────────────────────────────────── +# Each check: (payload, client=None) -> list[str] +# Return error strings. Empty list = pass. - payload = _load_json_file(file) - errors: list[str] = [] +def check_items_present(payload: dict, client=None) -> list[str]: items = payload.get("items") if not items or not isinstance(items, list): - errors.append("Payload must contain a non-empty 'items' array.") - else: - company_ids = set() - total_debits = Decimal(0) - total_credits = Decimal(0) - - for i, item in enumerate(items): - cid = item.get("company_id") - if cid is not None: - company_ids.add(cid) - - try: - debit = Decimal(str(item.get("debit", "0"))) - credit = Decimal(str(item.get("credit", "0"))) - except (InvalidOperation, TypeError): - errors.append(f"Item {i}: invalid debit/credit value.") - continue - - total_debits += debit - total_credits += credit - - if not errors: - if len(company_ids) < 2: - errors.append("Intercompany journal entries require lines across at least two distinct companies.") - - total_debits = total_debits.quantize(Decimal("0.01")) - total_credits = total_credits.quantize(Decimal("0.01")) - if total_debits != total_credits: - errors.append(f"Total debits ({total_debits}) must equal total credits ({total_credits}).") - - if errors: - for err in errors: - typer.secho(f" \u2717 {err}", fg=typer.colors.RED, err=True) - raise typer.Exit(code=1) - - typer.secho(" \u2713 Valid", fg=typer.colors.GREEN) + return ["Payload must contain a non-empty 'items' array."] + return [] + + +def check_amounts_valid(payload: dict, client=None) -> list[str]: + errors = [] + for i, item in enumerate(payload.get("items", [])): + try: + Decimal(str(item.get("debit", "0"))) + Decimal(str(item.get("credit", "0"))) + except (InvalidOperation, TypeError): + errors.append(f"Item {i}: invalid debit/credit value.") + return errors + + +def check_debits_equal_credits(payload: dict, client=None) -> list[str]: + total_debits = Decimal(0) + total_credits = Decimal(0) + for item in payload.get("items", []): + total_debits += Decimal(str(item.get("debit", "0"))) + total_credits += Decimal(str(item.get("credit", "0"))) + total_debits = total_debits.quantize(Decimal("0.01")) + total_credits = total_credits.quantize(Decimal("0.01")) + if total_debits != total_credits: + return [f"Total debits ({total_debits}) must equal total credits ({total_credits})."] + return [] + + +def check_multi_company(payload: dict, client=None) -> list[str]: + company_ids = {item.get("company_id") for item in payload.get("items", []) if item.get("company_id") is not None} + if len(company_ids) < 2: + return ["Intercompany journal entries require lines across at least two distinct companies."] + return [] + + +def check_company_access(payload: dict, client=None) -> list[str]: + if client is None: + return [] + company_ids = {item.get("company_id") for item in payload.get("items", []) if item.get("company_id") is not None} + if not company_ids: + return [] + data = client.get("/companies/", params={"limit": 100}) + accessible = {c["id"] for c in data.get("items", [])} + unknown = company_ids - accessible + if unknown: + return [f"Company IDs not accessible: {', '.join(str(c) for c in sorted(unknown))}"] + return [] + + +# ── Offline checks run in order; later checks assume earlier ones passed. +IJE_OFFLINE_CHECKS = [check_items_present, check_amounts_valid, check_debits_equal_credits, check_multi_company] +IJE_ONLINE_CHECKS = [check_company_access] + +IJE_CHECKS = IJE_OFFLINE_CHECKS +IJE_ONLINE_EXTRA_CHECKS = IJE_ONLINE_CHECKS diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index e8bdc0d..f50302f 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -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_TEMPLATE, validate_ije +from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE from dualentry_cli.config import Config app = typer.Typer(name="dualentry", help="DualEntry accounting CLI", no_args_is_help=True, cls=HelpfulGroup) @@ -18,33 +18,36 @@ 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), name="invoices") -app.add_typer(make_resource_app("bills", "bill", "bills", has_number=True), name="bills") +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(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), name="sales-orders") -app.add_typer(make_resource_app("customer payments", "customer-payment", "customer-payments", has_number=True), name="customer-payments") -app.add_typer(make_resource_app("customer credits", "customer-credit", "customer-credits", has_number=True), name="customer-credits") -app.add_typer(make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", has_number=True), name="customer-prepayments") +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 prepayment applications", "customer-prepayment-application", "customer-prepayment-applications", has_number=True), + make_resource_app("customer prepayments", "customer-prepayment", "customer-prepayments", 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_number=True, filters={"customer", "company"}), name="customer-prepayment-applications", ) -app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True), name="customer-deposits") -app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True), name="customer-refunds") -app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True), name="cash-sales") +app.add_typer(make_resource_app("customer deposits", "customer-deposit", "customer-deposits", has_number=True, filters={"customer", "company"}), name="customer-deposits") +app.add_typer(make_resource_app("customer refunds", "customer-refund", "customer-refunds", has_number=True, filters={"customer", "company"}), name="customer-refunds") +app.add_typer(make_resource_app("cash sales", "cash-sale", "cash-sales", has_number=True, filters={"customer", "company"}), name="cash-sales") # Money-out -app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True), name="purchase-orders") -app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True), name="vendor-payments") -app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True), name="vendor-credits") -app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True), name="vendor-prepayments") +app.add_typer(make_resource_app("purchase orders", "purchase-order", "purchase-orders", has_number=True, filters={"vendor", "company"}), name="purchase-orders") +app.add_typer(make_resource_app("vendor payments", "vendor-payment", "vendor-payments", has_number=True, filters={"vendor", "company"}), name="vendor-payments") +app.add_typer(make_resource_app("vendor credits", "vendor-credit", "vendor-credits", has_number=True, filters={"vendor", "company"}), name="vendor-credits") +app.add_typer(make_resource_app("vendor prepayments", "vendor-prepayment", "vendor-prepayments", has_number=True, filters={"vendor", "company"}), name="vendor-prepayments") app.add_typer( - make_resource_app("vendor prepayment applications", "vendor-prepayment-application", "vendor-prepayment-applications", has_number=True), name="vendor-prepayment-applications" + make_resource_app("vendor prepayment applications", "vendor-prepayment-application", "vendor-prepayment-applications", has_number=True, filters={"vendor", "company"}), + name="vendor-prepayment-applications", ) -app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True), name="vendor-refunds") -app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True), name="direct-expenses") +app.add_typer(make_resource_app("vendor refunds", "vendor-refund", "vendor-refunds", has_number=True, filters={"vendor", "company"}), name="vendor-refunds") +app.add_typer(make_resource_app("direct expenses", "direct-expense", "direct-expenses", has_number=True, filters={"vendor", "company"}), name="direct-expenses") # Accounting app.add_typer(make_resource_app("journal entries", "journal-entry", "journal-entries", has_number=True), name="journal-entries") @@ -77,8 +80,10 @@ "intercompany-journal-entries", has_number=True, has_post=True, + filters={"company"}, template=IJE_TEMPLATE, - validate_fn=validate_ije, + checks=IJE_CHECKS, + online_checks=IJE_ONLINE_EXTRA_CHECKS, ), name="intercompany-journal-entries", ) diff --git a/tests/test_intercompany_journal_entries.py b/tests/test_intercompany_journal_entries.py index f8ecd9c..6858a0e 100644 --- a/tests/test_intercompany_journal_entries.py +++ b/tests/test_intercompany_journal_entries.py @@ -326,3 +326,350 @@ def test_template_is_balanced(self): total_debits = sum(float(item["debit"]) for item in parsed["items"]) total_credits = sum(float(item["credit"]) for item in parsed["items"]) assert total_debits == total_credits + + +# ── Helpers ──────────────────────────────────────────────────────── + +_DRAFT_RESPONSE = { + "internal_id": 200, + "record_number": 55, + "date": "2026-05-01", + "transaction_date": "2026-05-01", + "memo": "IC transfer", + "currency_iso_4217_code": "USD", + "exchange_rate": "1.00000000", + "record_status": "draft", + "companies": [{"id": 1, "name": "Alpha Co"}, {"id": 2, "name": "Beta Co"}], + "company_ids": [1, 2], + "items": [ + { + "id": 10, + "company_id": 1, + "company_name": "Alpha Co", + "account_number": 1000, + "account_name": "Cash", + "debit": "5000.00", + "credit": "0.00", + "memo": "", + "position": 0, + "eliminate": True, + }, + { + "id": 11, + "company_id": 1, + "company_name": "Alpha Co", + "account_number": 2000, + "account_name": "Payable", + "debit": "0.00", + "credit": "5000.00", + "memo": "", + "position": 1, + "eliminate": True, + }, + { + "id": 12, + "company_id": 2, + "company_name": "Beta Co", + "account_number": 1000, + "account_name": "Cash", + "debit": "5000.00", + "credit": "0.00", + "memo": "", + "position": 2, + "eliminate": True, + }, + { + "id": 13, + "company_id": 2, + "company_name": "Beta Co", + "account_number": 2000, + "account_name": "Payable", + "debit": "0.00", + "credit": "5000.00", + "memo": "", + "position": 3, + "eliminate": True, + }, + ], +} + + +def _valid_payload(): + return { + "date": "2026-05-01", + "memo": "IC transfer", + "currency_iso_4217_code": "USD", + "exchange_rate": "1.00000000", + "record_status": "draft", + "items": [ + {"company_id": 1, "account_number": 1000, "debit": "5000.00", "credit": "0.00", "memo": "", "position": 0, "eliminate": True}, + {"company_id": 1, "account_number": 2000, "debit": "0.00", "credit": "5000.00", "memo": "", "position": 1, "eliminate": True}, + {"company_id": 2, "account_number": 1000, "debit": "5000.00", "credit": "0.00", "memo": "", "position": 2, "eliminate": True}, + {"company_id": 2, "account_number": 2000, "debit": "0.00", "credit": "5000.00", "memo": "", "position": 3, "eliminate": True}, + ], + } + + +# ── E2E workflow tests ───────────────────────────────────────────── + + +class TestIJEWorkflow: + """Full workflow: template -> validate -> create -> list -> get -> post.""" + + def test_template_validates_clean(self, tmp_path): + tmpl = runner.invoke(app, ["intercompany-journal-entries", "template"]) + assert tmpl.exit_code == 0 + f = tmp_path / "ije.json" + f.write_text(tmpl.output) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 0 + assert "Valid" in result.output + + def test_template_to_file_then_validate(self, tmp_path): + f = tmp_path / "ije.json" + runner.invoke(app, ["intercompany-journal-entries", "template", "--output", str(f)]) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 0 + + def test_validate_then_create(self, mock_get_client, tmp_path): + payload = _valid_payload() + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + + val = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert val.exit_code == 0 + + mock_get_client.post.return_value = {**_DRAFT_RESPONSE} + create = runner.invoke(app, ["intercompany-journal-entries", "create", "--file", str(f)]) + assert create.exit_code == 0 + mock_get_client.post.assert_called_once_with("/intercompany-journal-entries/", json=payload) + + def test_create_then_get_then_post(self, mock_get_client, tmp_path): + payload = _valid_payload() + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + + mock_get_client.post.return_value = {**_DRAFT_RESPONSE} + create = runner.invoke(app, ["intercompany-journal-entries", "create", "--file", str(f)]) + assert create.exit_code == 0 + + mock_get_client.get.return_value = {**_DRAFT_RESPONSE} + get = runner.invoke(app, ["intercompany-journal-entries", "get", "55"]) + assert get.exit_code == 0 + assert "IJE-200" in get.output + assert "Alpha Co" in get.output + assert "Beta Co" in get.output + + mock_get_client.get.return_value = {**_DRAFT_RESPONSE} + mock_get_client.put.return_value = {**_DRAFT_RESPONSE, "record_status": "posted"} + post = runner.invoke(app, ["intercompany-journal-entries", "post", "55"]) + assert post.exit_code == 0 + assert "POSTED" in post.output + + put_payload = mock_get_client.put.call_args[1]["json"] + assert put_payload["record_status"] == "posted" + assert "companies" not in put_payload + assert "company_ids" not in put_payload + assert "internal_id" not in put_payload + for item in put_payload["items"]: + assert "company_name" not in item + assert "account_name" not in item + + def test_list_with_company_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["intercompany-journal-entries", "list", "--company", "42"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["company_id"] == "42" + + def test_list_with_status_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["intercompany-journal-entries", "list", "--status", "draft"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["record_status"] == "draft" + + def test_list_with_date_filters(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["intercompany-journal-entries", "list", "--start-date", "2026-01-01", "--end-date", "2026-12-31"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["start_date"] == "2026-01-01" + assert call_params["end_date"] == "2026-12-31" + + def test_list_with_combined_filters(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke( + app, + [ + "intercompany-journal-entries", + "list", + "--company", + "7", + "--status", + "posted", + "--start-date", + "2026-01-01", + ], + ) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["company_id"] == "7" + assert call_params["record_status"] == "posted" + assert call_params["start_date"] == "2026-01-01" + + def test_list_without_filters_sends_no_extra_params(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["intercompany-journal-entries", "list"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert "company_id" not in call_params + assert "customer_id" not in call_params + assert "vendor_id" not in call_params + + def test_invoices_accept_customer_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["invoices", "list", "--customer", "99"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["customer_id"] == "99" + + def test_invoices_accept_company_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["invoices", "list", "--company", "5"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["company_id"] == "5" + + def test_invoices_reject_vendor_filter(self): + result = runner.invoke(app, ["invoices", "list", "--vendor", "1"]) + assert result.exit_code != 0 + + def test_bills_accept_vendor_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["bills", "list", "--vendor", "50"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["vendor_id"] == "50" + + def test_bills_accept_company_filter(self, mock_get_client): + mock_get_client.get.return_value = {"items": [], "count": 0} + result = runner.invoke(app, ["bills", "list", "--company", "3"]) + assert result.exit_code == 0 + call_params = mock_get_client.get.call_args[1]["params"] + assert call_params["company_id"] == "3" + + def test_bills_reject_customer_filter(self): + result = runner.invoke(app, ["bills", "list", "--customer", "1"]) + assert result.exit_code != 0 + + def test_ije_accept_company_reject_others(self): + result = runner.invoke(app, ["intercompany-journal-entries", "list", "--customer", "1"]) + assert result.exit_code != 0 + result = runner.invoke(app, ["intercompany-journal-entries", "list", "--vendor", "1"]) + assert result.exit_code != 0 + + def test_json_output(self, mock_get_client): + mock_get_client.get.return_value = {**_DRAFT_RESPONSE} + result = runner.invoke(app, ["intercompany-journal-entries", "get", "55", "--format", "json"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["internal_id"] == 200 + + def test_get_by_prefix(self, mock_get_client): + mock_get_client.get.return_value = {**_DRAFT_RESPONSE} + result = runner.invoke(app, ["intercompany-journal-entries", "get", "IJE-55"]) + assert result.exit_code == 0 + mock_get_client.get.assert_called_once_with("/intercompany-journal-entries/55/") + + +class TestIJEValidateComposition: + """Validate command: check composition, short-circuit, --online.""" + + def test_short_circuits_on_missing_items(self, tmp_path): + f = tmp_path / "ije.json" + f.write_text(json.dumps({"date": "2026-01-01"})) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 1 + assert "items" in result.output.lower() + assert "debit" not in result.output.lower() + assert "companies" not in result.output.lower() + + def test_short_circuits_on_invalid_amounts(self, tmp_path): + payload = { + "items": [ + {"company_id": 1, "account_number": 1000, "debit": "not-a-number", "credit": "0.00"}, + {"company_id": 2, "account_number": 2000, "debit": "0.00", "credit": "1000.00"}, + ], + } + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 1 + assert "invalid" in result.output.lower() + assert "companies" not in result.output.lower() + + def test_reports_balance_error(self, tmp_path): + payload = { + "items": [ + {"company_id": 1, "account_number": 1000, "debit": "1000.00", "credit": "0.00"}, + {"company_id": 2, "account_number": 2000, "debit": "0.00", "credit": "999.99"}, + ], + } + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 1 + assert "1000.00" in result.output + assert "999.99" in result.output + + def test_reports_single_company_error(self, tmp_path): + payload = { + "items": [ + {"company_id": 1, "account_number": 1000, "debit": "1000.00", "credit": "0.00"}, + {"company_id": 1, "account_number": 2000, "debit": "0.00", "credit": "1000.00"}, + ], + } + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 1 + assert "two" in result.output.lower() or "companies" in result.output.lower() + + def test_online_checks_company_access(self, mock_get_client, tmp_path): + mock_get_client.get.return_value = {"items": [{"id": 1}, {"id": 3}]} + payload = _valid_payload() + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f), "--online"]) + assert result.exit_code == 1 + assert "2" in result.output + assert "not accessible" in result.output.lower() + + def test_online_passes_when_all_companies_accessible(self, mock_get_client, tmp_path): + mock_get_client.get.return_value = {"items": [{"id": 1}, {"id": 2}]} + payload = _valid_payload() + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f), "--online"]) + assert result.exit_code == 0 + assert "Valid" in result.output + + def test_offline_skips_company_access_check(self, mock_get_client, tmp_path): + payload = _valid_payload() + f = tmp_path / "ije.json" + f.write_text(json.dumps(payload)) + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 0 + mock_get_client.get.assert_not_called() + + def test_invalid_json_file(self, tmp_path): + f = tmp_path / "bad.json" + f.write_text("not json {{{") + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", str(f)]) + assert result.exit_code == 1 + assert "invalid json" in result.output.lower() or "JSON" in result.output + + def test_missing_file(self): + result = runner.invoke(app, ["intercompany-journal-entries", "validate", "--file", "/nonexistent/ije.json"]) + assert result.exit_code == 1