scanye-py 0.1.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.
- scanye_py-0.1.0/LICENSE +21 -0
- scanye_py-0.1.0/PKG-INFO +91 -0
- scanye_py-0.1.0/README.md +56 -0
- scanye_py-0.1.0/pyproject.toml +73 -0
- scanye_py-0.1.0/setup.cfg +4 -0
- scanye_py-0.1.0/src/scanye/__init__.py +0 -0
- scanye_py-0.1.0/src/scanye/cli.py +364 -0
- scanye_py-0.1.0/src/scanye/client.py +330 -0
- scanye_py-0.1.0/src/scanye/exceptions.py +10 -0
- scanye_py-0.1.0/src/scanye/models.py +107 -0
- scanye_py-0.1.0/src/scanye/py.typed +0 -0
- scanye_py-0.1.0/src/scanye_py.egg-info/PKG-INFO +91 -0
- scanye_py-0.1.0/src/scanye_py.egg-info/SOURCES.txt +18 -0
- scanye_py-0.1.0/src/scanye_py.egg-info/dependency_links.txt +1 -0
- scanye_py-0.1.0/src/scanye_py.egg-info/entry_points.txt +2 -0
- scanye_py-0.1.0/src/scanye_py.egg-info/requires.txt +10 -0
- scanye_py-0.1.0/src/scanye_py.egg-info/top_level.txt +1 -0
- scanye_py-0.1.0/tests/test_cli.py +175 -0
- scanye_py-0.1.0/tests/test_client.py +247 -0
- scanye_py-0.1.0/tests/test_models.py +79 -0
scanye_py-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TheUndefined
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
scanye_py-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scanye-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python library and CLI for the Scanye accounting API
|
|
5
|
+
Author: TheUndefined
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/theundefined/scanye-py
|
|
8
|
+
Project-URL: Repository, https://github.com/theundefined/scanye-py
|
|
9
|
+
Project-URL: Issues, https://github.com/theundefined/scanye-py/issues
|
|
10
|
+
Keywords: scanye,invoicing,ksef,accounting,api-client
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
30
|
+
Requires-Dist: respx>=0.21.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=24.2.0; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy>=1.9.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Scanye Python Library and CLI
|
|
37
|
+
|
|
38
|
+
A Python library and CLI tool for interacting with the Scanye API.
|
|
39
|
+
|
|
40
|
+
> **Unofficial project.** This library is not created, maintained, sponsored, or endorsed by Scanye. It's a community-built client for the unofficial Scanye API (`api.scanye.pl`), reverse-engineered from the Scanye web app's network traffic. Use at your own risk.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- Login and authentication
|
|
44
|
+
- List sales and purchase invoices
|
|
45
|
+
- Filter invoices by KSeF status
|
|
46
|
+
- Download invoices as PDF
|
|
47
|
+
- CLI for easy access
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
```bash
|
|
51
|
+
pip install scanye-py
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## CLI Usage
|
|
55
|
+
First, login to your account:
|
|
56
|
+
```bash
|
|
57
|
+
scanye login --email your@email.com
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
List your sales invoices:
|
|
61
|
+
```bash
|
|
62
|
+
scanye invoices list --type sales
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
List purchase invoices that are not yet sent to KSeF:
|
|
66
|
+
```bash
|
|
67
|
+
scanye invoices list --type purchase --unsent
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Download all sales and purchase invoices from a given month as PDF (multiple invoices are downloaded as a single ZIP and extracted automatically):
|
|
71
|
+
```bash
|
|
72
|
+
scanye invoices download --type sales --month 2026-07 -o ./invoices/2026-07/sales
|
|
73
|
+
scanye invoices download --type purchase --month 2026-07 -o ./invoices/2026-07/purchase
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Download a single invoice by ID:
|
|
77
|
+
```bash
|
|
78
|
+
scanye invoices download <invoice-id> -o ./invoices
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Library Usage
|
|
82
|
+
```python
|
|
83
|
+
from scanye.client import ScanyeClient
|
|
84
|
+
|
|
85
|
+
client = ScanyeClient()
|
|
86
|
+
client.login("your@email.com", "your_password")
|
|
87
|
+
|
|
88
|
+
invoices = client.fetch_invoices(is_sales=True)
|
|
89
|
+
for inv in invoices:
|
|
90
|
+
print(f"{inv.invoice_no}: {inv.ksef_status}")
|
|
91
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Scanye Python Library and CLI
|
|
2
|
+
|
|
3
|
+
A Python library and CLI tool for interacting with the Scanye API.
|
|
4
|
+
|
|
5
|
+
> **Unofficial project.** This library is not created, maintained, sponsored, or endorsed by Scanye. It's a community-built client for the unofficial Scanye API (`api.scanye.pl`), reverse-engineered from the Scanye web app's network traffic. Use at your own risk.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
- Login and authentication
|
|
9
|
+
- List sales and purchase invoices
|
|
10
|
+
- Filter invoices by KSeF status
|
|
11
|
+
- Download invoices as PDF
|
|
12
|
+
- CLI for easy access
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
```bash
|
|
16
|
+
pip install scanye-py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI Usage
|
|
20
|
+
First, login to your account:
|
|
21
|
+
```bash
|
|
22
|
+
scanye login --email your@email.com
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
List your sales invoices:
|
|
26
|
+
```bash
|
|
27
|
+
scanye invoices list --type sales
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
List purchase invoices that are not yet sent to KSeF:
|
|
31
|
+
```bash
|
|
32
|
+
scanye invoices list --type purchase --unsent
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Download all sales and purchase invoices from a given month as PDF (multiple invoices are downloaded as a single ZIP and extracted automatically):
|
|
36
|
+
```bash
|
|
37
|
+
scanye invoices download --type sales --month 2026-07 -o ./invoices/2026-07/sales
|
|
38
|
+
scanye invoices download --type purchase --month 2026-07 -o ./invoices/2026-07/purchase
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Download a single invoice by ID:
|
|
42
|
+
```bash
|
|
43
|
+
scanye invoices download <invoice-id> -o ./invoices
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Library Usage
|
|
47
|
+
```python
|
|
48
|
+
from scanye.client import ScanyeClient
|
|
49
|
+
|
|
50
|
+
client = ScanyeClient()
|
|
51
|
+
client.login("your@email.com", "your_password")
|
|
52
|
+
|
|
53
|
+
invoices = client.fetch_invoices(is_sales=True)
|
|
54
|
+
for inv in invoices:
|
|
55
|
+
print(f"{inv.invoice_no}: {inv.ksef_status}")
|
|
56
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scanye-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial Python library and CLI for the Scanye accounting API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "TheUndefined" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = ["scanye", "invoicing", "ksef", "accounting", "api-client"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.27.0",
|
|
30
|
+
"python-dotenv>=1.0.1",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/theundefined/scanye-py"
|
|
35
|
+
Repository = "https://github.com/theundefined/scanye-py"
|
|
36
|
+
Issues = "https://github.com/theundefined/scanye-py/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
scanye = "scanye.cli:main"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0.0",
|
|
44
|
+
"pytest-asyncio>=0.23.0",
|
|
45
|
+
"respx>=0.21.0",
|
|
46
|
+
"ruff>=0.3.0",
|
|
47
|
+
"black>=24.2.0",
|
|
48
|
+
"mypy>=1.9.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
scanye = ["py.typed"]
|
|
53
|
+
|
|
54
|
+
[tool.black]
|
|
55
|
+
line-length = 120
|
|
56
|
+
target-version = ['py310']
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 120
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "W"]
|
|
64
|
+
ignore = []
|
|
65
|
+
|
|
66
|
+
[tool.mypy]
|
|
67
|
+
python_version = "3.10"
|
|
68
|
+
warn_return_any = true
|
|
69
|
+
warn_unused_configs = true
|
|
70
|
+
disallow_untyped_defs = true
|
|
71
|
+
files = "src"
|
|
72
|
+
mypy_path = "src"
|
|
73
|
+
explicit_package_bases = true
|
|
File without changes
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import zipfile
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from getpass import getpass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from .client import ScanyeClient
|
|
13
|
+
from .exceptions import ScanyeError
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path.home() / ".config" / "scanye"
|
|
16
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_config(config: dict) -> None:
|
|
20
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
os.chmod(CONFIG_DIR, 0o700)
|
|
22
|
+
fd = os.open(CONFIG_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
23
|
+
with os.fdopen(fd, "w") as f:
|
|
24
|
+
json.dump(config, f)
|
|
25
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config() -> dict:
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return {}
|
|
31
|
+
with open(CONFIG_FILE, "r") as f:
|
|
32
|
+
config = json.load(f)
|
|
33
|
+
if isinstance(config, dict):
|
|
34
|
+
return config
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_client(config: dict, debug: bool) -> ScanyeClient:
|
|
39
|
+
return ScanyeClient(
|
|
40
|
+
token=config.get("token"),
|
|
41
|
+
debug=debug,
|
|
42
|
+
email=config.get("email"),
|
|
43
|
+
password=config.get("password"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def persist_token(config: dict, client: ScanyeClient) -> None:
|
|
48
|
+
"""Save the client's current token if it changed (e.g. after an automatic re-login)."""
|
|
49
|
+
if client.token and client.token != config.get("token"):
|
|
50
|
+
config["token"] = client.token
|
|
51
|
+
save_config(config)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_credentials(config: dict) -> None:
|
|
55
|
+
if not config.get("token") and not (config.get("email") and config.get("password")):
|
|
56
|
+
print("Not logged in. Run 'scanye login' first.", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def handle_login(args: argparse.Namespace) -> None:
|
|
61
|
+
email = args.email
|
|
62
|
+
password = getpass(f"Password for {email}: ")
|
|
63
|
+
|
|
64
|
+
client = ScanyeClient(debug=args.debug)
|
|
65
|
+
try:
|
|
66
|
+
token = client.login(email, password)
|
|
67
|
+
config = {"token": token, "email": email}
|
|
68
|
+
|
|
69
|
+
answer = input("Save password so expired tokens can be refreshed automatically? [y/N]: ")
|
|
70
|
+
if answer.strip().lower() in ("y", "yes"):
|
|
71
|
+
config["password"] = password
|
|
72
|
+
save_config(config)
|
|
73
|
+
print("Login successful. Token and password saved.")
|
|
74
|
+
else:
|
|
75
|
+
save_config(config)
|
|
76
|
+
print("Login successful. Token saved.")
|
|
77
|
+
except ScanyeError as e:
|
|
78
|
+
print(f"Login failed: {e}", file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_month_filters(months: Optional[List[str]], raw_filter: Optional[str]) -> List[str]:
|
|
83
|
+
filters = ["dateAuthenticated?isNotNull"]
|
|
84
|
+
# Note: "unsent" isn't a real server-side field; callers apply it client-side
|
|
85
|
+
# after fetching, once ksef_status has been resolved from the raw invoice payload.
|
|
86
|
+
|
|
87
|
+
if raw_filter:
|
|
88
|
+
filters.append(raw_filter)
|
|
89
|
+
else:
|
|
90
|
+
now = datetime.now()
|
|
91
|
+
if not months:
|
|
92
|
+
# Default to a broad range for current year to show everything relevant
|
|
93
|
+
start_month = f"{now.year}-01"
|
|
94
|
+
end_month = f"{now.year + 1}-01"
|
|
95
|
+
filters.append(f"annotations.accountingMonth>={start_month}")
|
|
96
|
+
filters.append(f"annotations.accountingMonth<={end_month}")
|
|
97
|
+
else:
|
|
98
|
+
# Use specific range for requested months
|
|
99
|
+
# For simplicity, if multiple months are provided, we just use the range from min to max
|
|
100
|
+
sorted_months = sorted(months)
|
|
101
|
+
filters.append(f"annotations.accountingMonth>={sorted_months[0]}")
|
|
102
|
+
filters.append(f"annotations.accountingMonth<={sorted_months[-1]}")
|
|
103
|
+
|
|
104
|
+
return filters
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle_invoices_list(args: argparse.Namespace) -> None:
|
|
108
|
+
config = load_config()
|
|
109
|
+
require_credentials(config)
|
|
110
|
+
|
|
111
|
+
is_sales = args.type == "sales"
|
|
112
|
+
client = build_client(config, args.debug)
|
|
113
|
+
filters = build_month_filters(args.month, args.filter)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Fetch more if we are filtering client-side
|
|
117
|
+
fetch_limit = args.limit * 5 if args.unsent else args.limit
|
|
118
|
+
invoices = client.fetch_invoices(
|
|
119
|
+
is_sales=is_sales,
|
|
120
|
+
limit=fetch_limit,
|
|
121
|
+
filters=",".join(filters) if filters else None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if args.unsent:
|
|
125
|
+
invoices = [inv for inv in invoices if not inv.ksef_status or inv.ksef_status == "N/A"]
|
|
126
|
+
invoices = invoices[: args.limit]
|
|
127
|
+
|
|
128
|
+
if not invoices:
|
|
129
|
+
print("No invoices found.")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# "Client" for sales invoices (the buyer), "Seller" for purchase invoices (the vendor).
|
|
133
|
+
counterparty_label = "Client" if is_sales else "Seller"
|
|
134
|
+
|
|
135
|
+
# Header definition based on verbosity
|
|
136
|
+
if args.verbose:
|
|
137
|
+
h1 = f"{'ID':<38} | {'Date':<10} | {'Inv No':<15} | {'Gross':<10} | "
|
|
138
|
+
h2 = f"{'Paid':<10} | {'Tax No':<12} | {'Email':<25} | {counterparty_label}"
|
|
139
|
+
header = h1 + h2
|
|
140
|
+
else:
|
|
141
|
+
h1 = f"{'ID':<38} | {'Date':<10} | {'Inv No':<15} | {'Gross':<10} | "
|
|
142
|
+
h2 = f"{'Paid Date':<10} | {counterparty_label:<30} | {'KSeF'}"
|
|
143
|
+
header = h1 + h2
|
|
144
|
+
|
|
145
|
+
print(header)
|
|
146
|
+
print("-" * len(header))
|
|
147
|
+
for inv in invoices:
|
|
148
|
+
date = inv.issue_date or "N/A"
|
|
149
|
+
gross = inv.gross_amount or "0.00"
|
|
150
|
+
paid_date = inv.transfer_date or "N/A"
|
|
151
|
+
|
|
152
|
+
if args.verbose:
|
|
153
|
+
tax_no = inv.counterparty_tax_no or "N/A"
|
|
154
|
+
email = (inv.counterparty_email or "N/A")[:25]
|
|
155
|
+
counterparty = inv.counterparty_name or ""
|
|
156
|
+
p1 = f"{inv.id:<38} | {date:<10} | {inv.invoice_no:<15} | {gross:<10} | "
|
|
157
|
+
p2 = f"{paid_date:<10} | {tax_no:<12} | {email:<25} | {counterparty}"
|
|
158
|
+
print(p1 + p2)
|
|
159
|
+
else:
|
|
160
|
+
counterparty = (inv.counterparty_name or "")[:30]
|
|
161
|
+
ksef = inv.ksef_status or "N/A"
|
|
162
|
+
p1 = f"{inv.id:<38} | {date:<10} | {inv.invoice_no:<15} | {gross:<10} | "
|
|
163
|
+
p2 = f"{paid_date:<10} | {counterparty:<30} | {ksef}"
|
|
164
|
+
print(p1 + p2)
|
|
165
|
+
|
|
166
|
+
except ScanyeError as e:
|
|
167
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
finally:
|
|
170
|
+
persist_token(config, client)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def handle_invoices_mark_paid(args: argparse.Namespace) -> None:
|
|
174
|
+
config = load_config()
|
|
175
|
+
require_credentials(config)
|
|
176
|
+
|
|
177
|
+
client = build_client(config, args.debug)
|
|
178
|
+
try:
|
|
179
|
+
client.mark_as_paid(args.invoice_ids, transfer_date=args.date)
|
|
180
|
+
print(f"Successfully marked {len(args.invoice_ids)} invoices as paid.")
|
|
181
|
+
except ScanyeError as e:
|
|
182
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
finally:
|
|
185
|
+
persist_token(config, client)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def handle_invoices_mark_unpaid(args: argparse.Namespace) -> None:
|
|
189
|
+
config = load_config()
|
|
190
|
+
require_credentials(config)
|
|
191
|
+
|
|
192
|
+
client = build_client(config, args.debug)
|
|
193
|
+
try:
|
|
194
|
+
client.mark_as_unpaid(args.invoice_ids)
|
|
195
|
+
print(f"Successfully marked {len(args.invoice_ids)} invoices as unpaid.")
|
|
196
|
+
except ScanyeError as e:
|
|
197
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
finally:
|
|
200
|
+
persist_token(config, client)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def handle_invoices_send_ksef(args: argparse.Namespace) -> None:
|
|
204
|
+
config = load_config()
|
|
205
|
+
require_credentials(config)
|
|
206
|
+
|
|
207
|
+
client = build_client(config, args.debug)
|
|
208
|
+
invoice_ids = args.invoice_ids or []
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
if args.all:
|
|
212
|
+
print("Searching for unsent sales invoices...")
|
|
213
|
+
# Fetch invoices from last few months to be safe
|
|
214
|
+
now = datetime.now()
|
|
215
|
+
start_month = f"{now.year if now.month > 1 else now.year - 1}-{max(1, (now.month - 2) % 12 or 12):02d}"
|
|
216
|
+
filters = [
|
|
217
|
+
"dateAuthenticated?isNotNull",
|
|
218
|
+
f"annotations.accountingMonth>={start_month}",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
invoices = client.fetch_invoices(
|
|
222
|
+
is_sales=True,
|
|
223
|
+
limit=100,
|
|
224
|
+
filters=",".join(filters),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Filter for invoices that are not sent to KSeF
|
|
228
|
+
to_send = [inv.id for inv in invoices if not inv.ksef_status or inv.ksef_status == "N/A"]
|
|
229
|
+
|
|
230
|
+
if not to_send:
|
|
231
|
+
print("No unsent invoices found.")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
print(f"Found {len(to_send)} unsent invoices.")
|
|
235
|
+
invoice_ids.extend(to_send)
|
|
236
|
+
|
|
237
|
+
if not invoice_ids:
|
|
238
|
+
print("No invoice IDs provided and --all not specified.", file=sys.stderr)
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
|
|
241
|
+
print(f"Sending {len(invoice_ids)} invoices to KSeF...")
|
|
242
|
+
client.send_to_ksef(invoice_ids)
|
|
243
|
+
print("Successfully initiated sending to KSeF.")
|
|
244
|
+
except ScanyeError as e:
|
|
245
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
246
|
+
sys.exit(1)
|
|
247
|
+
finally:
|
|
248
|
+
persist_token(config, client)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def handle_invoices_download(args: argparse.Namespace) -> None:
|
|
252
|
+
config = load_config()
|
|
253
|
+
require_credentials(config)
|
|
254
|
+
|
|
255
|
+
if args.invoice_ids and (args.month or args.filter):
|
|
256
|
+
print("Cannot combine specific invoice IDs with --month/--filter.", file=sys.stderr)
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
|
|
259
|
+
is_sales = args.type == "sales"
|
|
260
|
+
client = build_client(config, args.debug)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
if args.invoice_ids:
|
|
264
|
+
invoice_ids = args.invoice_ids
|
|
265
|
+
else:
|
|
266
|
+
filters = build_month_filters(args.month, args.filter)
|
|
267
|
+
invoices = client.fetch_invoices(
|
|
268
|
+
is_sales=is_sales,
|
|
269
|
+
limit=args.limit,
|
|
270
|
+
filters=",".join(filters),
|
|
271
|
+
)
|
|
272
|
+
invoice_ids = [inv.id for inv in invoices]
|
|
273
|
+
|
|
274
|
+
if not invoice_ids:
|
|
275
|
+
print("No invoices found to download.")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
output_dir = Path(args.output)
|
|
279
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
|
|
281
|
+
print(f"Downloading {len(invoice_ids)} invoice(s)...")
|
|
282
|
+
content, filename = client.fetch_printout(invoice_ids)
|
|
283
|
+
|
|
284
|
+
if zipfile.is_zipfile(io.BytesIO(content)):
|
|
285
|
+
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
|
286
|
+
names = zf.namelist()
|
|
287
|
+
zf.extractall(output_dir)
|
|
288
|
+
print(f"Saved {len(names)} file(s) to {output_dir}/")
|
|
289
|
+
else:
|
|
290
|
+
path = output_dir / Path(filename).name
|
|
291
|
+
path.write_bytes(content)
|
|
292
|
+
print(f"Saved {path}")
|
|
293
|
+
except ScanyeError as e:
|
|
294
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
finally:
|
|
297
|
+
persist_token(config, client)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main() -> None:
|
|
301
|
+
parser = argparse.ArgumentParser(description="Scanye CLI tool")
|
|
302
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
303
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
304
|
+
|
|
305
|
+
# Login command
|
|
306
|
+
login_parser = subparsers.add_parser("login", help="Login to Scanye")
|
|
307
|
+
login_parser.add_argument("--email", required=True, help="Your Scanye email")
|
|
308
|
+
|
|
309
|
+
# Invoices command
|
|
310
|
+
invoice_parser = subparsers.add_parser("invoices", help="Invoice operations")
|
|
311
|
+
invoice_subparsers = invoice_parser.add_subparsers(dest="subcommand", help="Invoice subcommands")
|
|
312
|
+
|
|
313
|
+
list_parser = invoice_subparsers.add_parser("list", help="List invoices")
|
|
314
|
+
list_parser.add_argument("--type", choices=["sales", "purchase"], default="sales", help="Invoice type")
|
|
315
|
+
list_parser.add_argument("--limit", type=int, default=10, help="Limit number of invoices")
|
|
316
|
+
list_parser.add_argument("--unsent", action="store_true", help="List only unsent to KSeF")
|
|
317
|
+
list_parser.add_argument("--month", action="append", help="Month(s) to fetch (YYYY-MM), e.g. 2026-05")
|
|
318
|
+
list_parser.add_argument("--filter", help="Raw filter string for API")
|
|
319
|
+
list_parser.add_argument("-v", "--verbose", action="store_true", help="Show more details (NIP, email)")
|
|
320
|
+
|
|
321
|
+
paid_parser = invoice_subparsers.add_parser("mark-paid", help="Mark invoices as paid")
|
|
322
|
+
paid_parser.add_argument("invoice_ids", nargs="+", help="Invoice IDs to mark as paid")
|
|
323
|
+
paid_parser.add_argument("--date", help="Transfer order date (YYYY-MM-DD), defaults to today")
|
|
324
|
+
|
|
325
|
+
unpaid_parser = invoice_subparsers.add_parser("mark-unpaid", help="Mark invoices as unpaid")
|
|
326
|
+
unpaid_parser.add_argument("invoice_ids", nargs="+", help="Invoice IDs to mark as unpaid")
|
|
327
|
+
|
|
328
|
+
ksef_parser = invoice_subparsers.add_parser("send-ksef", help="Send invoices to KSeF")
|
|
329
|
+
ksef_parser.add_argument("invoice_ids", nargs="*", help="Specific invoice IDs to send")
|
|
330
|
+
ksef_parser.add_argument("--all", action="store_true", help="Automatically send all unsent sales invoices")
|
|
331
|
+
|
|
332
|
+
download_parser = invoice_subparsers.add_parser("download", help="Download invoices as PDF")
|
|
333
|
+
download_parser.add_argument(
|
|
334
|
+
"invoice_ids", nargs="*", help="Specific invoice IDs to download (omit to use --month/--filter instead)"
|
|
335
|
+
)
|
|
336
|
+
download_parser.add_argument("--type", choices=["sales", "purchase"], default="sales", help="Invoice type")
|
|
337
|
+
download_parser.add_argument("--month", action="append", help="Month(s) to fetch (YYYY-MM), e.g. 2026-07")
|
|
338
|
+
download_parser.add_argument("--filter", help="Raw filter string for API")
|
|
339
|
+
download_parser.add_argument("--limit", type=int, default=100, help="Max invoices to download when using filters")
|
|
340
|
+
download_parser.add_argument("-o", "--output", default=".", help="Output directory (default: current directory)")
|
|
341
|
+
|
|
342
|
+
args = parser.parse_args()
|
|
343
|
+
|
|
344
|
+
if args.command == "login":
|
|
345
|
+
handle_login(args)
|
|
346
|
+
elif args.command == "invoices":
|
|
347
|
+
if args.subcommand == "list":
|
|
348
|
+
handle_invoices_list(args)
|
|
349
|
+
elif args.subcommand == "mark-paid":
|
|
350
|
+
handle_invoices_mark_paid(args)
|
|
351
|
+
elif args.subcommand == "mark-unpaid":
|
|
352
|
+
handle_invoices_mark_unpaid(args)
|
|
353
|
+
elif args.subcommand == "send-ksef":
|
|
354
|
+
handle_invoices_send_ksef(args)
|
|
355
|
+
elif args.subcommand == "download":
|
|
356
|
+
handle_invoices_download(args)
|
|
357
|
+
else:
|
|
358
|
+
invoice_parser.print_help()
|
|
359
|
+
else:
|
|
360
|
+
parser.print_help()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == "__main__":
|
|
364
|
+
main()
|