freeagent-cli 0.2.1__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.
- freeagent_cli-0.6.0/.github/workflows/ci.yml +77 -0
- freeagent_cli-0.6.0/.github/workflows/workflow.yml +74 -0
- freeagent_cli-0.6.0/PKG-INFO +158 -0
- freeagent_cli-0.6.0/README.md +131 -0
- {freeagent_cli-0.2.1 → freeagent_cli-0.6.0}/pyproject.toml +27 -1
- freeagent_cli-0.6.0/src/freeagent_cli/__init__.py +1 -0
- freeagent_cli-0.6.0/src/freeagent_cli/api.py +180 -0
- {freeagent_cli-0.2.1 → freeagent_cli-0.6.0}/src/freeagent_cli/auth.py +5 -2
- freeagent_cli-0.6.0/src/freeagent_cli/cli.py +839 -0
- freeagent_cli-0.6.0/tests/__init__.py +0 -0
- freeagent_cli-0.6.0/tests/test_api.py +305 -0
- freeagent_cli-0.6.0/tests/test_cli.py +1018 -0
- freeagent_cli-0.6.0/tests/test_helpers.py +124 -0
- freeagent_cli-0.6.0/uv.lock +216 -0
- freeagent_cli-0.2.1/PKG-INFO +0 -93
- freeagent_cli-0.2.1/README.md +0 -66
- freeagent_cli-0.2.1/src/freeagent_cli/__init__.py +0 -1
- freeagent_cli-0.2.1/src/freeagent_cli/api.py +0 -73
- freeagent_cli-0.2.1/src/freeagent_cli/cli.py +0 -290
- freeagent_cli-0.2.1/uv.lock +0 -127
- {freeagent_cli-0.2.1 → freeagent_cli-0.6.0}/.gitignore +0 -0
- {freeagent_cli-0.2.1 → freeagent_cli-0.6.0}/LICENSE +0 -0
- {freeagent_cli-0.2.1 → freeagent_cli-0.6.0}/src/freeagent_cli/config.py +0 -0
|
@@ -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
|
+
[](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
|
+
[](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
|
+
version = "0.6.0"
|
|
4
4
|
description = "CLI for submitting FreeAgent timeslips"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -40,3 +40,29 @@ build-backend = "hatchling.build"
|
|
|
40
40
|
|
|
41
41
|
[tool.hatch.build.targets.wheel]
|
|
42
42
|
packages = ["src/freeagent_cli"]
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=9.0.3",
|
|
47
|
+
"ruff>=0.14",
|
|
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
|
-
|
|
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):
|
|
28
|
+
def do_GET(self):
|
|
26
29
|
parsed = urlparse(self.path)
|
|
27
30
|
if parsed.path != "/callback":
|
|
28
31
|
self.send_response(404)
|