freeagent-cli 0.3.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ # Deliberately unfiltered: PRs are sometimes stacked on another feature
7
+ # branch rather than main, and those need checking too.
8
+ pull_request:
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ lint:
19
+ name: lint
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v7.0.1
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v9.0.0
26
+ with:
27
+ enable-cache: true
28
+
29
+ - name: Install dependencies
30
+ run: uv sync --locked
31
+
32
+ # Rules and version both come from pyproject.toml, so a ruff release
33
+ # can't change what CI enforces without a deliberate bump.
34
+ - name: Ruff
35
+ run: uv run --no-sync ruff check --output-format=github .
36
+
37
+ test:
38
+ name: tests (py${{ matrix.python-version }})
39
+ runs-on: ubuntu-latest
40
+ strategy:
41
+ fail-fast: false
42
+ matrix:
43
+ # Matches requires-python and the classifiers in pyproject.toml.
44
+ python-version: ["3.11", "3.12", "3.13"]
45
+ steps:
46
+ - uses: actions/checkout@v7.0.1
47
+
48
+ - name: Install uv
49
+ uses: astral-sh/setup-uv@v9.0.0
50
+ with:
51
+ enable-cache: true
52
+
53
+ # --locked fails if uv.lock has drifted from pyproject.toml, so a
54
+ # dependency change that wasn't re-locked is caught here rather than
55
+ # by whoever installs the tool next.
56
+ - name: Install dependencies
57
+ run: uv sync --locked --python ${{ matrix.python-version }}
58
+
59
+ - name: Run tests
60
+ run: uv run --no-sync pytest -q
61
+
62
+ build:
63
+ name: build
64
+ runs-on: ubuntu-latest
65
+ steps:
66
+ - uses: actions/checkout@v7.0.1
67
+
68
+ - name: Install uv
69
+ uses: astral-sh/setup-uv@v9.0.0
70
+
71
+ - name: Build sdist and wheel
72
+ run: uv build
73
+
74
+ # The console script is the whole product, so check it actually runs
75
+ # from the built wheel. Catches packaging mistakes tests can't see.
76
+ - name: Smoke-test the built wheel
77
+ run: uv run --no-project --with dist/*.whl freeagent-cli --help
@@ -0,0 +1,74 @@
1
+ name: Release
2
+
3
+ # Filename matters: PyPI trusted publishing matches the workflow file by name,
4
+ # so this must stay `workflow.yml` to match the publisher configured on PyPI.
5
+ # Renaming it breaks publishing until the PyPI side is updated to match.
6
+
7
+ on:
8
+ push:
9
+ tags: ["v*"]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ build:
16
+ name: build and verify
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7.0.1
20
+
21
+ - name: Install uv
22
+ uses: astral-sh/setup-uv@v9.0.0
23
+ with:
24
+ enable-cache: true
25
+
26
+ - name: Install dependencies
27
+ run: uv sync --locked
28
+
29
+ # A tag that disagrees with pyproject.toml would publish a version nobody
30
+ # asked for, and PyPI version numbers cannot be reused. Fail loudly first.
31
+ - name: Check the tag matches the packaged version
32
+ run: |
33
+ tag="${GITHUB_REF_NAME#v}"
34
+ packaged=$(uv run --no-sync python -c \
35
+ "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
36
+ echo "tag=$tag packaged=$packaged"
37
+ if [ "$tag" != "$packaged" ]; then
38
+ echo "::error::Tag $GITHUB_REF_NAME implies $tag but pyproject.toml says $packaged"
39
+ exit 1
40
+ fi
41
+
42
+ - name: Ruff
43
+ run: uv run --no-sync ruff check --output-format=github .
44
+
45
+ - name: Run tests
46
+ run: uv run --no-sync pytest -q
47
+
48
+ - name: Build sdist and wheel
49
+ run: uv build
50
+
51
+ - name: Smoke-test the built wheel
52
+ run: uv run --no-project --with dist/*.whl freeagent-cli --help
53
+
54
+ - uses: actions/upload-artifact@v4
55
+ with:
56
+ name: dist
57
+ path: dist/
58
+
59
+ publish:
60
+ name: publish to PyPI
61
+ needs: build
62
+ runs-on: ubuntu-latest
63
+ # OIDC: the short-lived credential is minted here, so no PyPI token is
64
+ # stored anywhere. Nothing else in this job may need write access.
65
+ permissions:
66
+ id-token: write
67
+ steps:
68
+ - uses: actions/download-artifact@v4
69
+ with:
70
+ name: dist
71
+ path: dist/
72
+
73
+ - name: Publish
74
+ uses: pypa/gh-action-pypi-publish@v1.14.1
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: freeagent-cli
3
+ Version: 0.6.0
4
+ Summary: CLI for submitting FreeAgent timeslips
5
+ Project-URL: Homepage, https://github.com/tomdyson/freeagent-cli
6
+ Project-URL: Repository, https://github.com/tomdyson/freeagent-cli
7
+ Project-URL: Issues, https://github.com/tomdyson/freeagent-cli/issues
8
+ Author-email: Tom Dyson <tom@naive.co.uk>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,freeagent,timeslip,timetracking
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: platformdirs>=4.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # freeagent-cli
29
+
30
+ [![CI](https://github.com/tomdyson/freeagent-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/tomdyson/freeagent-cli/actions/workflows/ci.yml)
31
+
32
+ A small CLI for submitting FreeAgent timeslips without clicking through the web UI.
33
+
34
+ ```
35
+ freeagent-cli log acme 1h30m "fixed the thing"
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```
41
+ uv tool install freeagent-cli
42
+ ```
43
+
44
+ ## One-time setup
45
+
46
+ You'll need to register your own OAuth app with FreeAgent. It takes about two minutes and keeps your data and rate limits separate from everyone else's.
47
+
48
+ 1. Go to <https://dev.freeagent.com/apps> and create a new app.
49
+ 2. Set the redirect URI to: `http://localhost:7878/callback`
50
+ 3. Note the **OAuth identifier** and **OAuth secret**.
51
+ 4. Save them locally:
52
+
53
+ ```
54
+ freeagent-cli auth init --client-id <id> --client-secret <secret>
55
+ ```
56
+
57
+ Add `--sandbox` if you want to test against the FreeAgent sandbox first.
58
+ 5. Authorise the app in your browser:
59
+
60
+ ```
61
+ freeagent-cli auth login
62
+ ```
63
+
64
+ A browser tab opens, you approve, and the CLI captures the refresh token. The refresh token lasts ~20 years; access tokens auto-refresh on every command.
65
+
66
+ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.json` (macOS) or the equivalent platform config directory, with file mode `0600`.
67
+
68
+ ## Usage
69
+
70
+ ```
71
+ freeagent-cli --help # canonical flow
72
+ freeagent-cli recent # what you've already logged (run this first to avoid duplicates)
73
+ freeagent-cli log <project> <duration> [comment...] # submit a timeslip
74
+ freeagent-cli projects # first-time / discovery: projects + tasks in one call
75
+ freeagent-cli accounts # bank accounts + balances
76
+ freeagent-cli unexplained # bank transactions still needing an explanation
77
+ ```
78
+
79
+ Examples:
80
+
81
+ ```
82
+ freeagent-cli log Acme 1h30m "fixed the thing"
83
+ freeagent-cli log Acme 90m fixed the thing # comment without quotes
84
+ freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
85
+ freeagent-cli log Acme 1.5 --dry-run # preview, don't submit
86
+ ```
87
+
88
+ - **Duration** accepts `1.5`, `90m`, `1h30m`, or `1:30`.
89
+ - **Project / task** match by case-insensitive name substring, numeric id, or full URL.
90
+ - **`--task`** is optional when the project has a single task; otherwise the error lists the choices.
91
+ - **`--date`** defaults to today (ISO `YYYY-MM-DD` to override).
92
+ - **`--dry-run`** resolves the project/task/date and prints the would-be submission without sending it.
93
+ - **`projects --flat`** emits one project/task pair per line (tab-separated) for grep/awk.
94
+
95
+ ## Banking
96
+
97
+ ```
98
+ freeagent-cli accounts # id, name, currency, balance
99
+ freeagent-cli unexplained # what still needs explaining
100
+ freeagent-cli unexplained --account Current --days 365
101
+ freeagent-cli unexplained -n 0 | grep -i stripe # the whole backlog, filtered
102
+ ```
103
+
104
+ `unexplained` prints one tab-separated transaction per line — date, unexplained amount, description, count of similar transactions, marker, URL — most recent first. The marker reads `partial` when only part of a transaction has been explained.
105
+
106
+ - **`--account`** is optional if you have a single active bank account; otherwise the error lists the choices. Matches by name substring, id, or URL, like `--project`.
107
+ - **`--days`** defaults to 90. Pass `0` for no date limit.
108
+ - **`-n`** defaults to 25. Pass `0` for all.
109
+ - A summary (count and total) goes to **stderr**, so piping stdout into `grep`/`awk` stays clean.
110
+
111
+ Hidden accounts are left out of `accounts` unless you pass `--all`, and are never picked as the implicit default for `unexplained`. Naming one explicitly with `--account` still works.
112
+
113
+ ### Explaining transactions
114
+
115
+ ```
116
+ freeagent-cli categories --search travel # find a category
117
+ freeagent-cli explain 12345 285 --dry-run # preview
118
+ freeagent-cli explain 12345 285 --description "train fare" # submit
119
+ freeagent-cli explain 12345 285 --amount 20 # explain part of it
120
+ ```
121
+
122
+ `categories` lists nominal code, group and description, one per line. Filter with `--search` or `--group` (`admin_expenses`, `cost_of_sales`, `income`, `general`).
123
+
124
+ - **CATEGORY** matches by nominal code (`285`), full URL, or case-insensitive description substring. An ambiguous match lists the candidates with their codes.
125
+ - **`--like <transaction>`** reuses the category from a transaction you've already explained, instead of naming one. See below.
126
+ - **`--amount`** explains part of a transaction; it defaults to the whole unexplained amount and can't exceed it. Currency symbols and commas are ignored.
127
+ - **The sign always comes from the transaction**, so `--amount 20` on a payment of `-42.50` explains `-20.00`. You can't accidentally book a spend as income.
128
+ - **`--date`** defaults to the transaction's own date.
129
+ - **`--dry-run`** previews; otherwise you're asked to confirm. `-y` skips the prompt.
130
+
131
+ **VAT:** `explain` doesn't set sales-tax fields, so FreeAgent applies the category's automatic rate. If a transaction needs a non-standard rate, EC status, or a manual VAT amount, do that one in the web UI.
132
+
133
+ ### Recurring payees
134
+
135
+ Most of a backlog is the same handful of payees every month. `--like` copies the category from a transaction you've already explained, so you don't have to remember which one it was:
136
+
137
+ ```
138
+ freeagent-cli explain 12345 --like 9999
139
+ ```
140
+
141
+ The `similar` column in `unexplained` (from `matching_transactions_count`) tells you when a transaction has precedent worth copying.
142
+
143
+ `--like` reads the category and nothing else — the amount, date and description still come from the transaction being explained, or from your flags. It refuses rather than guesses when there's no single answer:
144
+
145
+ - The source has no category — invoice payments, bill payments and transfers aren't categorised.
146
+ - The source is split across several categories, in which case the error lists them so you can pick one.
147
+
148
+ A typical backlog session:
149
+
150
+ ```
151
+ freeagent-cli unexplained
152
+ freeagent-cli explain 12345 285 --description "client dinner"
153
+ freeagent-cli explain 12346 --like 12345
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,131 @@
1
+ # freeagent-cli
2
+
3
+ [![CI](https://github.com/tomdyson/freeagent-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/tomdyson/freeagent-cli/actions/workflows/ci.yml)
4
+
5
+ A small CLI for submitting FreeAgent timeslips without clicking through the web UI.
6
+
7
+ ```
8
+ freeagent-cli log acme 1h30m "fixed the thing"
9
+ ```
10
+
11
+ ## Install
12
+
13
+ ```
14
+ uv tool install freeagent-cli
15
+ ```
16
+
17
+ ## One-time setup
18
+
19
+ You'll need to register your own OAuth app with FreeAgent. It takes about two minutes and keeps your data and rate limits separate from everyone else's.
20
+
21
+ 1. Go to <https://dev.freeagent.com/apps> and create a new app.
22
+ 2. Set the redirect URI to: `http://localhost:7878/callback`
23
+ 3. Note the **OAuth identifier** and **OAuth secret**.
24
+ 4. Save them locally:
25
+
26
+ ```
27
+ freeagent-cli auth init --client-id <id> --client-secret <secret>
28
+ ```
29
+
30
+ Add `--sandbox` if you want to test against the FreeAgent sandbox first.
31
+ 5. Authorise the app in your browser:
32
+
33
+ ```
34
+ freeagent-cli auth login
35
+ ```
36
+
37
+ A browser tab opens, you approve, and the CLI captures the refresh token. The refresh token lasts ~20 years; access tokens auto-refresh on every command.
38
+
39
+ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.json` (macOS) or the equivalent platform config directory, with file mode `0600`.
40
+
41
+ ## Usage
42
+
43
+ ```
44
+ freeagent-cli --help # canonical flow
45
+ freeagent-cli recent # what you've already logged (run this first to avoid duplicates)
46
+ freeagent-cli log <project> <duration> [comment...] # submit a timeslip
47
+ freeagent-cli projects # first-time / discovery: projects + tasks in one call
48
+ freeagent-cli accounts # bank accounts + balances
49
+ freeagent-cli unexplained # bank transactions still needing an explanation
50
+ ```
51
+
52
+ Examples:
53
+
54
+ ```
55
+ freeagent-cli log Acme 1h30m "fixed the thing"
56
+ freeagent-cli log Acme 90m fixed the thing # comment without quotes
57
+ freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
58
+ freeagent-cli log Acme 1.5 --dry-run # preview, don't submit
59
+ ```
60
+
61
+ - **Duration** accepts `1.5`, `90m`, `1h30m`, or `1:30`.
62
+ - **Project / task** match by case-insensitive name substring, numeric id, or full URL.
63
+ - **`--task`** is optional when the project has a single task; otherwise the error lists the choices.
64
+ - **`--date`** defaults to today (ISO `YYYY-MM-DD` to override).
65
+ - **`--dry-run`** resolves the project/task/date and prints the would-be submission without sending it.
66
+ - **`projects --flat`** emits one project/task pair per line (tab-separated) for grep/awk.
67
+
68
+ ## Banking
69
+
70
+ ```
71
+ freeagent-cli accounts # id, name, currency, balance
72
+ freeagent-cli unexplained # what still needs explaining
73
+ freeagent-cli unexplained --account Current --days 365
74
+ freeagent-cli unexplained -n 0 | grep -i stripe # the whole backlog, filtered
75
+ ```
76
+
77
+ `unexplained` prints one tab-separated transaction per line — date, unexplained amount, description, count of similar transactions, marker, URL — most recent first. The marker reads `partial` when only part of a transaction has been explained.
78
+
79
+ - **`--account`** is optional if you have a single active bank account; otherwise the error lists the choices. Matches by name substring, id, or URL, like `--project`.
80
+ - **`--days`** defaults to 90. Pass `0` for no date limit.
81
+ - **`-n`** defaults to 25. Pass `0` for all.
82
+ - A summary (count and total) goes to **stderr**, so piping stdout into `grep`/`awk` stays clean.
83
+
84
+ Hidden accounts are left out of `accounts` unless you pass `--all`, and are never picked as the implicit default for `unexplained`. Naming one explicitly with `--account` still works.
85
+
86
+ ### Explaining transactions
87
+
88
+ ```
89
+ freeagent-cli categories --search travel # find a category
90
+ freeagent-cli explain 12345 285 --dry-run # preview
91
+ freeagent-cli explain 12345 285 --description "train fare" # submit
92
+ freeagent-cli explain 12345 285 --amount 20 # explain part of it
93
+ ```
94
+
95
+ `categories` lists nominal code, group and description, one per line. Filter with `--search` or `--group` (`admin_expenses`, `cost_of_sales`, `income`, `general`).
96
+
97
+ - **CATEGORY** matches by nominal code (`285`), full URL, or case-insensitive description substring. An ambiguous match lists the candidates with their codes.
98
+ - **`--like <transaction>`** reuses the category from a transaction you've already explained, instead of naming one. See below.
99
+ - **`--amount`** explains part of a transaction; it defaults to the whole unexplained amount and can't exceed it. Currency symbols and commas are ignored.
100
+ - **The sign always comes from the transaction**, so `--amount 20` on a payment of `-42.50` explains `-20.00`. You can't accidentally book a spend as income.
101
+ - **`--date`** defaults to the transaction's own date.
102
+ - **`--dry-run`** previews; otherwise you're asked to confirm. `-y` skips the prompt.
103
+
104
+ **VAT:** `explain` doesn't set sales-tax fields, so FreeAgent applies the category's automatic rate. If a transaction needs a non-standard rate, EC status, or a manual VAT amount, do that one in the web UI.
105
+
106
+ ### Recurring payees
107
+
108
+ Most of a backlog is the same handful of payees every month. `--like` copies the category from a transaction you've already explained, so you don't have to remember which one it was:
109
+
110
+ ```
111
+ freeagent-cli explain 12345 --like 9999
112
+ ```
113
+
114
+ The `similar` column in `unexplained` (from `matching_transactions_count`) tells you when a transaction has precedent worth copying.
115
+
116
+ `--like` reads the category and nothing else — the amount, date and description still come from the transaction being explained, or from your flags. It refuses rather than guesses when there's no single answer:
117
+
118
+ - The source has no category — invoice payments, bill payments and transfers aren't categorised.
119
+ - The source is split across several categories, in which case the error lists them so you can pick one.
120
+
121
+ A typical backlog session:
122
+
123
+ ```
124
+ freeagent-cli unexplained
125
+ freeagent-cli explain 12345 285 --description "client dinner"
126
+ freeagent-cli explain 12346 --like 12345
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "freeagent-cli"
3
- version = "0.3.0"
3
+ version = "0.6.0"
4
4
  description = "CLI for submitting FreeAgent timeslips"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -44,4 +44,25 @@ packages = ["src/freeagent_cli"]
44
44
  [dependency-groups]
45
45
  dev = [
46
46
  "pytest>=9.0.3",
47
+ "ruff>=0.14",
47
48
  ]
49
+
50
+ [tool.ruff]
51
+ # Wide enough for the existing tab-separated click.echo lines and help epilogs,
52
+ # which read worse wrapped than long.
53
+ line-length = 110
54
+ target-version = "py311"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "N", "RUF"]
58
+ ignore = [
59
+ # `date.today()` is correct here. Timeslips and bank transactions are dated
60
+ # in the user's local day; a tz-aware UTC date would file work on the wrong
61
+ # day either side of midnight. Kept out of `select` rather than noqa'd at
62
+ # each of the three call sites.
63
+ "DTZ011",
64
+ ]
65
+
66
+ [tool.ruff.lint.per-file-ignores]
67
+ # Tests lean on long inline fixtures where wrapping hurts more than it helps.
68
+ "tests/*" = ["E501"]
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from . import __version__, auth
6
+ from . import config as cfg
7
+
8
+ #: FreeAgent caps per_page at 100; asking for the maximum keeps round trips down.
9
+ PER_PAGE = 100
10
+
11
+ #: Safety valve so a runaway Link chain can't loop forever. 100 pages = 10,000 records.
12
+ MAX_PAGES = 100
13
+
14
+
15
+ class FreeAgent:
16
+ def __init__(self, c: cfg.Config):
17
+ self.cfg = c
18
+
19
+ def _client(self) -> httpx.Client:
20
+ return httpx.Client(
21
+ base_url=self.cfg.api_base,
22
+ headers={
23
+ "Authorization": f"Bearer {auth.access_token(self.cfg)}",
24
+ "Accept": "application/json",
25
+ "User-Agent": f"freeagent-cli/{__version__}",
26
+ },
27
+ timeout=30,
28
+ )
29
+
30
+ def get(self, path: str, **params) -> dict:
31
+ with self._client() as c:
32
+ r = c.get(path, params=params)
33
+ r.raise_for_status()
34
+ return r.json()
35
+
36
+ def get_all(self, path: str, key: str, **params) -> list[dict]:
37
+ """GET a list endpoint, following `Link: rel="next"` until exhausted.
38
+
39
+ FreeAgent returns 25 records per page by default, so anything that reads a
40
+ single response silently truncates. Params with a value of None are dropped.
41
+ """
42
+ query = {k: v for k, v in params.items() if v is not None}
43
+ query.setdefault("per_page", PER_PAGE)
44
+ items: list[dict] = []
45
+ with self._client() as c:
46
+ url = path
47
+ # Only the first request needs params; each `next` URL carries its own.
48
+ request_params: dict | None = query
49
+ for _ in range(MAX_PAGES):
50
+ r = c.get(url, params=request_params)
51
+ r.raise_for_status()
52
+ items.extend(r.json().get(key, []))
53
+ next_link = r.links.get("next")
54
+ if not next_link:
55
+ return items
56
+ url = next_link["url"]
57
+ request_params = None
58
+ raise RuntimeError(
59
+ f"Stopped after {MAX_PAGES} pages of {path}; narrow the date range."
60
+ )
61
+
62
+ def post(self, path: str, json_body: dict) -> dict:
63
+ with self._client() as c:
64
+ r = c.post(path, json=json_body)
65
+ if r.status_code >= 400:
66
+ raise httpx.HTTPStatusError(
67
+ f"{r.status_code} {r.reason_phrase}: {r.text}",
68
+ request=r.request, response=r,
69
+ )
70
+ return r.json()
71
+
72
+ def me(self) -> dict:
73
+ return self.get("/v2/users/me")["user"]
74
+
75
+ def projects(self, view: str = "active") -> list[dict]:
76
+ return self.get_all("/v2/projects", "projects", view=view)
77
+
78
+ def tasks(self, project_url: str) -> list[dict]:
79
+ return self.get_all("/v2/tasks", "tasks", project=project_url)
80
+
81
+ def list_timeslips(self, *, from_date: str, to_date: str | None = None,
82
+ user: str | None = None, nested: bool = False) -> list[dict]:
83
+ return self.get_all(
84
+ "/v2/timeslips", "timeslips",
85
+ from_date=from_date, to_date=to_date, user=user,
86
+ nested="true" if nested else None,
87
+ )
88
+
89
+ def create_timeslip(self, *, user: str, project: str, task: str,
90
+ dated_on: str, hours: float, comment: str | None = None) -> dict:
91
+ body: dict = {
92
+ "timeslip": {
93
+ "user": user,
94
+ "project": project,
95
+ "task": task,
96
+ "dated_on": dated_on,
97
+ "hours": str(hours),
98
+ }
99
+ }
100
+ if comment:
101
+ body["timeslip"]["comment"] = comment
102
+ return self.post("/v2/timeslips", body)
103
+
104
+ # -- banking ---------------------------------------------------------
105
+
106
+ def bank_accounts(self, view: str | None = None) -> list[dict]:
107
+ return self.get_all("/v2/bank_accounts", "bank_accounts", view=view)
108
+
109
+ def bank_transactions(self, *, bank_account: str, view: str = "all",
110
+ from_date: str | None = None,
111
+ to_date: str | None = None) -> list[dict]:
112
+ """List transactions for one account. `bank_account` is required by the API."""
113
+ return self.get_all(
114
+ "/v2/bank_transactions", "bank_transactions",
115
+ bank_account=bank_account, view=view,
116
+ from_date=from_date, to_date=to_date,
117
+ )
118
+
119
+ def bank_transaction(self, txn_id: str) -> dict:
120
+ return self.get(f"/v2/bank_transactions/{txn_id}")["bank_transaction"]
121
+
122
+ def categories(self) -> list[dict]:
123
+ """Flatten the four parallel category arrays into one list.
124
+
125
+ The API groups categories into `admin_expenses_categories`,
126
+ `cost_of_sales_categories`, `income_categories` and `general_categories`.
127
+ Each entry is tagged with the group it came from. Note that categories
128
+ name themselves with `description`, not `name`, and are keyed by nominal
129
+ code — which can carry a leading zero, e.g. "001".
130
+ """
131
+ data = self.get("/v2/categories", per_page=PER_PAGE)
132
+ out: list[dict] = []
133
+ for group, items in data.items():
134
+ if not group.endswith("_categories") or not isinstance(items, list):
135
+ continue
136
+ label = group[: -len("_categories")]
137
+ out.extend({**c, "group": label} for c in items)
138
+ return out
139
+
140
+ def explanation(self, exp_id: str) -> dict:
141
+ return self.get(
142
+ f"/v2/bank_transaction_explanations/{exp_id}"
143
+ )["bank_transaction_explanation"]
144
+
145
+ def create_explanation(self, *, bank_transaction: str, dated_on: str,
146
+ gross_value: str, category: str,
147
+ description: str | None = None) -> dict:
148
+ """Explain (part of) a bank transaction against a category.
149
+
150
+ Sales-tax fields are deliberately omitted so FreeAgent applies the
151
+ category's automatic rate rather than this CLI guessing at VAT.
152
+ """
153
+ body: dict = {
154
+ "bank_transaction_explanation": {
155
+ "bank_transaction": bank_transaction,
156
+ "dated_on": dated_on,
157
+ "gross_value": gross_value,
158
+ "category": category,
159
+ }
160
+ }
161
+ if description:
162
+ body["bank_transaction_explanation"]["description"] = description
163
+ return self.post("/v2/bank_transaction_explanations", body)
164
+
165
+ def delete(self, path: str) -> dict:
166
+ with self._client() as c:
167
+ r = c.delete(path)
168
+ r.raise_for_status()
169
+ return r.json()
170
+
171
+ def delete_timeslip(self, timeslip_id: str) -> dict:
172
+ try:
173
+ return self.delete(f"/v2/timeslips/{timeslip_id}")
174
+ except httpx.HTTPStatusError as e:
175
+ if e.response.status_code == 404:
176
+ return {"deleted": True, "id": timeslip_id, "already_deleted": True}
177
+ raise
178
+
179
+ def get_timeslip(self, timeslip_id: str) -> dict:
180
+ return self.get(f"/v2/timeslips/{timeslip_id}", nested="true")["timeslip"]
@@ -4,6 +4,7 @@ import secrets
4
4
  import time
5
5
  import webbrowser
6
6
  from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from typing import ClassVar
7
8
  from urllib.parse import parse_qs, urlencode, urlparse
8
9
 
9
10
  import httpx
@@ -20,9 +21,11 @@ def _authorize_url(c: cfg.Config) -> str:
20
21
 
21
22
 
22
23
  class _Handler(BaseHTTPRequestHandler):
23
- received: dict[str, str] = {}
24
+ # Shared on the class on purpose: HTTPServer instantiates a new handler per
25
+ # request, so this is how the callback hands the result back to login().
26
+ received: ClassVar[dict[str, str]] = {}
24
27
 
25
- def do_GET(self): # noqa: N802
28
+ def do_GET(self):
26
29
  parsed = urlparse(self.path)
27
30
  if parsed.path != "/callback":
28
31
  self.send_response(404)