From 6d81078a7d8c695cb771a922a049710a44814bf3 Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:16:40 -0400 Subject: [PATCH 1/2] Add gst-tax-rates list and get commands Add read-only `dualentry gst-tax-rates list` and `get`, for looking up the gst_tax_rate_id a line item needs. - list filters by --country (AU, NZ, SG, IN, CA, MY; repeatable), --treatment, --active/--inactive, and --updated-after/--updated-before. Country and treatment choices are checked before any request, and an integration test compares them with the API's published schema. - Updated-time filters must be ISO 8601 with a timezone, which the API requires; the error says how to add one. - Rates are shown as percentages (the API returns fractions such as 0.180000), and get shows the component split for rates such as CGST + SGST. JSON output keeps the API's values unchanged. - README gains a Tax row listing the VAT, GST, and product tax code commands. --- CHANGELOG.md | 1 + README.md | 1 + src/dualentry_cli/commands/gst_tax_rates.py | 110 +++++++++++++ src/dualentry_cli/main.py | 2 + src/dualentry_cli/output.py | 107 +++++++++++++ tests/test_gst_tax_rates.py | 165 ++++++++++++++++++++ tests/test_integration.py | 21 +++ 7 files changed, 407 insertions(+) create mode 100644 src/dualentry_cli/commands/gst_tax_rates.py create mode 100644 tests/test_gst_tax_rates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe986b..81a9d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Add `gst-tax-rates` commands (`list`, `get`) to look up the `gst_tax_rate_id` for line items, filterable by `--country`, `--treatment`, `--active/--inactive`, and `--updated-after/--updated-before`. Rates show as percentages, with the component split for rates such as CGST + SGST - Line-item tables label the quantity column Quantity - Update the CLI greeting logo to DualEntry - Add `export-jobs` commands (`create`, `list`, `get`, `wait`) for on-demand destination data refreshes via the public API diff --git a/README.md b/README.md index 66f75e6..2610ae6 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ dualentry export-jobs wait 42 --interval 10 --timeout 1800 | **Payables** | Bills, Purchase Orders, Vendor Payments, Credits, Refunds | | **Accounting** | Journal Entries, Bank Transfers, Fixed Assets, Depreciation | | **Master Data** | Customers, Vendors, Items, Accounts, Classifications | +| **Tax** | VAT Rates, GST Tax Rates, Product Tax Codes | | **Automation** | Recurring Invoices, Recurring Bills, Workflows, Contracts | | **Close Management** | Bank Match | | **Bank Feeds** | Bank Connections | diff --git a/src/dualentry_cli/commands/gst_tax_rates.py b/src/dualentry_cli/commands/gst_tax_rates.py new file mode 100644 index 0000000..d82d847 --- /dev/null +++ b/src/dualentry_cli/commands/gst_tax_rates.py @@ -0,0 +1,110 @@ +""" +GST tax rate commands (read-only), for looking up the `gst_tax_rate_id` a line item needs. + +The list endpoint filters by country, treatment, and active state, which the resource +factory's shared flags can't express, so this module defines its own `list`. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +import typer + +from dualentry_cli.cli import HelpfulGroup +from dualentry_cli.commands import AllPages, Format, Limit, Offset +from dualentry_cli.commands.actions import run_get, run_list + +app = typer.Typer(help="Look up GST tax rates (AU, NZ, SG, IN, CA GST and MY SST)", no_args_is_help=True, cls=HelpfulGroup) + + +# Mirrors the API's country and treatment enums, so a typo fails here with the valid +# choices listed, rather than as a 422 from the server. test_integration.py checks +# these against the API's published OpenAPI schema, so drift fails a test. +class GstCountry(StrEnum): + AU = "AU" + NZ = "NZ" + SG = "SG" + IN = "IN" + CA = "CA" + MY = "MY" + + +class GstTreatment(StrEnum): + STANDARD = "STANDARD" + ZERO_RATED = "ZERO_RATED" + EXEMPT = "EXEMPT" + IMPORT_GST = "IMPORT_GST" + REVERSE_CHARGE = "REVERSE_CHARGE" + SELF_ASSESSED = "SELF_ASSESSED" + OUT_OF_SCOPE = "OUT_OF_SCOPE" + + +def _aware_timestamp(value: str | None) -> str | None: + """ + Check an `--updated-*` value is an ISO 8601 timestamp with a timezone. + + Args: + value: The option value, or None when the flag was not passed. + + Returns: + The value unchanged, for the API to parse. + + Raises: + typer.BadParameter: If it isn't ISO 8601, or has no timezone. The API rejects + naive timestamps, and the CLI can say why more clearly than a 422. + + """ + if value is None: + return None + try: + moment = datetime.fromisoformat(value) + except ValueError as e: + raise typer.BadParameter(f"expected an ISO 8601 timestamp such as 2026-10-01T00:00:00Z, got {value!r}") from e + if moment.tzinfo is None: + raise typer.BadParameter(f"{value!r} has no timezone; add Z or an offset such as +10:00") + return value + + +@app.command("list") +def list_cmd( + limit: int = Limit, + offset: int = Offset, + all_pages: bool = AllPages, + country: list[GstCountry] | None = typer.Option(None, "--country", case_sensitive=False, help="Filter by country; repeat for several"), + treatment: GstTreatment | None = typer.Option(None, "--treatment", case_sensitive=False, help="Filter by tax treatment"), + active: bool | None = typer.Option(None, "--active/--inactive", help="Only active or only inactive rates [default: both]"), + updated_after: str | None = typer.Option( + None, "--updated-after", callback=_aware_timestamp, metavar="", help="Updated at or after this ISO 8601 timestamp (with timezone)" + ), + updated_before: str | None = typer.Option( + None, "--updated-before", callback=_aware_timestamp, metavar="", help="Updated at or before this ISO 8601 timestamp (with timezone)" + ), + output: str = Format, +) -> None: + """List GST tax rates. Use a rate's ID as `gst_tax_rate_id` on line items.""" + # TODO(#39): pass resume_flags for these filters once run_list accepts them. Until + # then the --all resume hint omits them; it only appears past 100,000 rates. + run_list( + "gst-tax-rates", + resource="gst-tax-rate", + limit=limit, + offset=offset, + all_pages=all_pages, + output=output, + country_code=[c.value for c in country] if country else None, + tax_treatment=treatment.value if treatment else None, + is_active=active, + updated_after=updated_after, + updated_before=updated_before, + ) + + +@app.command("get") +def get_cmd( + rate_id: int = typer.Argument(help="GST tax rate ID"), + output: str = Format, +) -> None: + """Get a GST tax rate, with its component breakdown for split rates.""" + run_get(f"/gst-tax-rates/{rate_id}/", resource="gst-tax-rate", output=output) diff --git a/src/dualentry_cli/main.py b/src/dualentry_cli/main.py index 66f0cb9..21ed5e2 100644 --- a/src/dualentry_cli/main.py +++ b/src/dualentry_cli/main.py @@ -11,6 +11,7 @@ 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.export_jobs import app as export_jobs_app +from dualentry_cli.commands.gst_tax_rates import app as gst_tax_rates_app from dualentry_cli.commands.ije_extras import IJE_CHECKS, IJE_ONLINE_EXTRA_CHECKS, IJE_TEMPLATE from dualentry_cli.commands.statistical_journals import app as statistical_journals_app from dualentry_cli.config import Config @@ -80,6 +81,7 @@ make_resource_app("VAT rates", "vat-rate", "vat-rates", has_create=False, has_update=False, filters=set()), name="vat-rates", ) +app.add_typer(gst_tax_rates_app, name="gst-tax-rates") # Read-only, with country and treatment filters # Recurring recurring_app = typer.Typer(help="Manage recurring records", no_args_is_help=True, cls=HelpfulGroup) diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index 7162c53..d6d53a5 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +from decimal import Decimal, InvalidOperation +from typing import TypedDict from rich.console import Console from rich.panel import Panel @@ -1320,6 +1322,111 @@ def _vat_rate_detail(r): _register("vat-rate", _vat_rate_list, _vat_rate_detail) +# ── GST tax rate ───────────────────────────────────────────────────── + + +class GstTaxRateComponent(TypedDict): + """One part of a split rate, e.g. the CGST half of an India intra-state rate.""" + + id: int + component_type: str + rate: str + region: str | None + position: int + + +class GstTaxRate(TypedDict): + """One rate from `/gst-tax-rates/`. `rate` is a fraction: "0.100000" is 10%.""" + + id: int + country_code: str + name: str + rate: str + tax_treatment: str + supply_category: str | None + region: str | None + valid_from: str + valid_to: str | None + is_active: bool + is_system: bool + description: str | None + components: list[GstTaxRateComponent] + + +def _fraction_as_percent(value: str | float) -> str: + """ + Show an API rate fraction as a percentage: "0.100000" → "10%", "0.087500" → "8.75%". + + The raw fraction is easy to misread as a percentage (0.1%), so human output + converts it. A value that isn't a number is shown as given. + """ + try: + percent = (Decimal(str(value)) * 100).normalize() + except InvalidOperation: + return str(value) + if not percent.is_finite(): + return str(value) + return f"{percent:f}%" + + +def _gst_tax_rate_list(items: list[GstTaxRate]) -> None: + table = Table(title="GST Tax Rates", show_lines=False) + table.add_column("ID", style="dim", justify="right") + table.add_column("Country") + table.add_column("Name", min_width=20, style="bold") + table.add_column("Treatment") + table.add_column("Rate", justify="right") + table.add_column("Active") + + for r in items: + table.add_row( + str(r["id"]), + r["country_code"], + r["name"], + r["tax_treatment"], + _fraction_as_percent(r["rate"]), + "yes" if r["is_active"] else "no", + ) + + console.print(table) + + +def _gst_tax_rate_detail(r: GstTaxRate) -> None: + header = Text() + header.append("GST TAX RATE", style="bold") + header.append(f" {r['name']}", style="bold cyan") + console.print(Panel(header, expand=False)) + + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", min_width=16) + details.add_column() + details.add_row("ID:", str(r["id"])) + details.add_row("Country:", r["country_code"] + (f" ({r['region']})" if r["region"] else "")) + details.add_row("Rate:", _fraction_as_percent(r["rate"])) + details.add_row("Treatment:", r["tax_treatment"]) + if r["supply_category"]: + details.add_row("Supply:", r["supply_category"]) + details.add_row("Valid:", f"{r['valid_from']} to {r['valid_to'] or 'open-ended'}") + details.add_row("Active:", "yes" if r["is_active"] else "no") + details.add_row("System rate:", "yes" if r["is_system"] else "no") + if r["description"]: + details.add_row("Description:", r["description"]) + console.print(details) + + # Split rates (CGST + SGST, GST + PST) apply as parts; show how the total breaks down. + if r["components"]: + parts = Table(title="Components", show_lines=False) + parts.add_column("Type", style="bold") + parts.add_column("Rate", justify="right") + parts.add_column("Region") + for c in sorted(r["components"], key=lambda c: c["position"]): + parts.add_row(c["component_type"], _fraction_as_percent(c["rate"]), c["region"] or "-") + console.print(parts) + + +_register("gst-tax-rate", _gst_tax_rate_list, _gst_tax_rate_detail) + + # ── Statistical journal ────────────────────────────────────────────── diff --git a/tests/test_gst_tax_rates.py b/tests/test_gst_tax_rates.py new file mode 100644 index 0000000..118fdf9 --- /dev/null +++ b/tests/test_gst_tax_rates.py @@ -0,0 +1,165 @@ +"""Tests for gst-tax-rates commands.""" + +from __future__ import annotations + +import json +import re +from unittest.mock import MagicMock, patch + +import pytest +import typer.main +from typer.testing import CliRunner + +from dualentry_cli.main import app +from dualentry_cli.output import _fraction_as_percent + +runner = CliRunner() + +_RATE = { + "id": 12, + "country_code": "IN", + "name": "GST standard intra-state", + "rate": "0.180000", + "tax_treatment": "STANDARD", + "supply_category": "INTRA_STATE", + "region": "KA", + "valid_from": "2017-07-01", + "valid_to": None, + "is_active": True, + "is_system": True, + "description": None, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "components": [ + {"id": 2, "component_type": "SGST", "rate": "0.090000", "region": "KA", "position": 1}, + {"id": 1, "component_type": "CGST", "rate": "0.090000", "region": None, "position": 0}, + ], +} + + +def _flat(output: str) -> str: + """Strip ANSI codes and rich box borders, and collapse wrapping, so messages match in CI too.""" + plain = re.sub(r"\x1b\[[0-9;]*m", "", output) + return " ".join(plain.replace("│", " ").split()) + + +@pytest.fixture +def client(): + mock = MagicMock() + with patch("dualentry_cli.main.get_client", return_value=mock): + yield mock + + +def test_is_read_only(): + assert set(typer.main.get_command(app).commands["gst-tax-rates"].commands) == {"list", "get"} + + +class TestList: + def test_no_filters(self, client): + client.get.return_value = {"items": [_RATE], "count": 1} + result = runner.invoke(app, ["gst-tax-rates", "list"]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with("/gst-tax-rates/", params={"limit": 20, "offset": 0}) + out = _flat(result.output) + assert "GST standard intra-state" in out + assert "18%" in out + assert "0.180000" not in out + + def test_every_filter_maps_to_its_api_name(self, client): + client.get.return_value = {"items": [], "count": 0} + result = runner.invoke( + app, + [ + "gst-tax-rates", "list", + "--country", "IN", "--country", "au", + "--treatment", "zero_rated", + "--inactive", + "--updated-after", "2026-01-01T00:00:00Z", + "--updated-before", "2026-06-30T23:59:59+10:00", + ], + ) # fmt: skip + assert result.exit_code == 0, result.output + client.get.assert_called_once_with( + "/gst-tax-rates/", + params={ + "country_code": ["IN", "AU"], + "tax_treatment": "ZERO_RATED", + "is_active": False, + "updated_after": "2026-01-01T00:00:00Z", + "updated_before": "2026-06-30T23:59:59+10:00", + "limit": 20, + "offset": 0, + }, + ) + + @pytest.mark.parametrize(("flag", "value"), [("--country", "US"), ("--country", "GB"), ("--treatment", "ZERO")]) + def test_unknown_choice_is_rejected_before_any_request(self, flag, value, client): + result = runner.invoke(app, ["gst-tax-rates", "list", flag, value]) + assert result.exit_code == 2 + assert "is not one of" in _flat(result.output) + client.get.assert_not_called() + + @pytest.mark.parametrize( + ("value", "message"), + [ + ("2026-01-01T00:00:00", "has no timezone"), + ("2026-01-01", "has no timezone"), + ("yesterday", "expected an ISO 8601 timestamp"), + ], + ) + @pytest.mark.parametrize("flag", ["--updated-after", "--updated-before"]) + def test_updated_filters_need_a_timezone_aware_iso_timestamp(self, flag, value, message, client): + result = runner.invoke(app, ["gst-tax-rates", "list", flag, value]) + assert result.exit_code == 2 + assert message in _flat(result.output) + client.get.assert_not_called() + + def test_json_output_keeps_the_api_fraction(self, client): + client.get.return_value = {"items": [_RATE], "count": 1} + result = runner.invoke(app, ["gst-tax-rates", "list", "-o", "json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.output)["items"][0]["rate"] == "0.180000" + + +class TestGet: + def test_gets_by_id_with_components_in_position_order(self, client): + client.get.return_value = _RATE + result = runner.invoke(app, ["gst-tax-rates", "get", "12"]) + assert result.exit_code == 0, result.output + client.get.assert_called_once_with("/gst-tax-rates/12/", params=None) + out = _flat(result.output) + assert "IN (KA)" in out + assert "INTRA_STATE" in out + assert "open-ended" in out + assert out.index("CGST") < out.index("SGST") + assert "9%" in out + + def test_rate_without_components_has_no_breakdown(self, client): + client.get.return_value = {**_RATE, "components": [], "supply_category": None, "region": None} + result = runner.invoke(app, ["gst-tax-rates", "get", "12"]) + out = _flat(result.output) + assert "Components" not in out + assert "Supply" not in out + + @pytest.mark.parametrize("value", ["abc", "1.5", "GST-12"]) + def test_id_must_be_an_integer(self, value, client): + result = runner.invoke(app, ["gst-tax-rates", "get", value]) + assert result.exit_code == 2 + client.get.assert_not_called() + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("0.100000", "10%"), + ("0.087500", "8.75%"), + ("0.000000", "0%"), + ("0.150000", "15%"), + (0.05, "5%"), + ("n/a", "n/a"), + ("NaN", "NaN"), + ("Infinity", "Infinity"), + ], +) +def test_fraction_as_percent(value, expected): + assert _fraction_as_percent(value) == expected diff --git a/tests/test_integration.py b/tests/test_integration.py index a829cf7..1d8da30 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,10 +11,12 @@ from __future__ import annotations import os +from enum import StrEnum import pytest from dualentry_cli.client import DualEntryClient +from dualentry_cli.commands.gst_tax_rates import GstCountry, GstTreatment from dualentry_cli.config import Config # ── Fixtures ──────────────────────────────────────────────────────── @@ -149,6 +151,25 @@ def test_id_resource_get(client: DualEntryClient, path: str, id_field: str, deta assert_record(detail, detail_keys) +# ── GST tax rates (list only: an org outside GST countries may have none) ── + + +def test_gst_tax_rates_list(client: DualEntryClient): + data = client.get("/gst-tax-rates/", params={"limit": 2}) + assert_list(data) + if data["items"]: + item = data["items"][0] + assert {"id", "country_code", "rate", "tax_treatment", "components"} <= item.keys() + assert isinstance(item["rate"], str) + + +@pytest.mark.parametrize(("schema", "cli_enum"), [("GstCountries", GstCountry), ("GstTreatmentType", GstTreatment)]) +def test_gst_choices_match_the_api_schema(client: DualEntryClient, schema: str, cli_enum: type[StrEnum]): + """--country and --treatment hardcode the API's enums; fail when the API adds or drops a value.""" + spec = client.get("/openapi.json") + assert set(spec["components"]["schemas"][schema]["enum"]) == {e.value for e in cli_enum} + + # ── Accounts (uses account number in URL) ─────────────────────────── From c28136c0371a9ef7b3ac4ad85bc879a8be8d8e80 Mon Sep 17 00:00:00 2001 From: Andy Woods <22278911+awoods187@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:22:00 -0400 Subject: [PATCH 2/2] Show the region in the gst-tax-rates list Regional rates (e.g. Canadian HST by province) otherwise appear as identical rows; get already showed it. --- src/dualentry_cli/output.py | 9 +++++++-- tests/test_gst_tax_rates.py | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dualentry_cli/output.py b/src/dualentry_cli/output.py index d6d53a5..1e83140 100644 --- a/src/dualentry_cli/output.py +++ b/src/dualentry_cli/output.py @@ -1369,6 +1369,11 @@ def _fraction_as_percent(value: str | float) -> str: return f"{percent:f}%" +def _gst_place(r: GstTaxRate) -> str: + """Country, plus the province or state when the rate is regional (e.g. "CA (ON)").""" + return r["country_code"] + (f" ({r['region']})" if r["region"] else "") + + def _gst_tax_rate_list(items: list[GstTaxRate]) -> None: table = Table(title="GST Tax Rates", show_lines=False) table.add_column("ID", style="dim", justify="right") @@ -1381,7 +1386,7 @@ def _gst_tax_rate_list(items: list[GstTaxRate]) -> None: for r in items: table.add_row( str(r["id"]), - r["country_code"], + _gst_place(r), r["name"], r["tax_treatment"], _fraction_as_percent(r["rate"]), @@ -1401,7 +1406,7 @@ def _gst_tax_rate_detail(r: GstTaxRate) -> None: details.add_column(style="dim", min_width=16) details.add_column() details.add_row("ID:", str(r["id"])) - details.add_row("Country:", r["country_code"] + (f" ({r['region']})" if r["region"] else "")) + details.add_row("Country:", _gst_place(r)) details.add_row("Rate:", _fraction_as_percent(r["rate"])) details.add_row("Treatment:", r["tax_treatment"]) if r["supply_category"]: diff --git a/tests/test_gst_tax_rates.py b/tests/test_gst_tax_rates.py index 118fdf9..40369c7 100644 --- a/tests/test_gst_tax_rates.py +++ b/tests/test_gst_tax_rates.py @@ -62,6 +62,7 @@ def test_no_filters(self, client): client.get.assert_called_once_with("/gst-tax-rates/", params={"limit": 20, "offset": 0}) out = _flat(result.output) assert "GST standard intra-state" in out + assert "IN (KA)" in out assert "18%" in out assert "0.180000" not in out