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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
110 changes: 110 additions & 0 deletions src/dualentry_cli/commands/gst_tax_rates.py
Original file line number Diff line number Diff line change
@@ -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="<timestamp>", help="Updated at or after this ISO 8601 timestamp (with timezone)"
),
updated_before: str | None = typer.Option(
None, "--updated-before", callback=_aware_timestamp, metavar="<timestamp>", 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)
2 changes: 2 additions & 0 deletions src/dualentry_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
112 changes: 112 additions & 0 deletions src/dualentry_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1320,6 +1322,116 @@ 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_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")
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"]),
_gst_place(r),
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:", _gst_place(r))
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 ──────────────────────────────────────────────


Expand Down
Loading
Loading