rechnomat 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.
Files changed (48) hide show
  1. rechnomat-0.1.0/LICENSE +21 -0
  2. rechnomat-0.1.0/MANIFEST.in +1 -0
  3. rechnomat-0.1.0/PKG-INFO +110 -0
  4. rechnomat-0.1.0/README.md +78 -0
  5. rechnomat-0.1.0/pyproject.toml +78 -0
  6. rechnomat-0.1.0/setup.cfg +4 -0
  7. rechnomat-0.1.0/src/rechnomat/__main__.py +34 -0
  8. rechnomat-0.1.0/src/rechnomat/cli.py +127 -0
  9. rechnomat-0.1.0/src/rechnomat/command/add_invoice.py +80 -0
  10. rechnomat-0.1.0/src/rechnomat/command/info.py +25 -0
  11. rechnomat-0.1.0/src/rechnomat/command/init.py +36 -0
  12. rechnomat-0.1.0/src/rechnomat/command/render_invoice.py +56 -0
  13. rechnomat-0.1.0/src/rechnomat/formatting.py +66 -0
  14. rechnomat-0.1.0/src/rechnomat/invoice_calc.py +64 -0
  15. rechnomat-0.1.0/src/rechnomat/invoice_html.py +75 -0
  16. rechnomat-0.1.0/src/rechnomat/invoice_numbering.py +52 -0
  17. rechnomat-0.1.0/src/rechnomat/invoice_pdf.py +65 -0
  18. rechnomat-0.1.0/src/rechnomat/letter_address.py +21 -0
  19. rechnomat-0.1.0/src/rechnomat/model.py +159 -0
  20. rechnomat-0.1.0/src/rechnomat/resources/backgrounds/letterhead.pdf +79 -0
  21. rechnomat-0.1.0/src/rechnomat/resources/customers/meier-gmbh.yml +12 -0
  22. rechnomat-0.1.0/src/rechnomat/resources/invoices/DE000001.yml +34 -0
  23. rechnomat-0.1.0/src/rechnomat/resources/seller/seller.yml +19 -0
  24. rechnomat-0.1.0/src/rechnomat/resources/templates/de/fonts/LiberationSans-Bold.ttf +0 -0
  25. rechnomat-0.1.0/src/rechnomat/resources/templates/de/fonts/LiberationSans-Regular.ttf +0 -0
  26. rechnomat-0.1.0/src/rechnomat/resources/templates/de/fonts/OFL.txt +102 -0
  27. rechnomat-0.1.0/src/rechnomat/resources/templates/de/template.css +180 -0
  28. rechnomat-0.1.0/src/rechnomat/resources/templates/de/template.html +114 -0
  29. rechnomat-0.1.0/src/rechnomat/theme.py +247 -0
  30. rechnomat-0.1.0/src/rechnomat/ui.py +230 -0
  31. rechnomat-0.1.0/src/rechnomat/yaml_io.py +12 -0
  32. rechnomat-0.1.0/src/rechnomat.egg-info/PKG-INFO +110 -0
  33. rechnomat-0.1.0/src/rechnomat.egg-info/SOURCES.txt +46 -0
  34. rechnomat-0.1.0/src/rechnomat.egg-info/dependency_links.txt +1 -0
  35. rechnomat-0.1.0/src/rechnomat.egg-info/entry_points.txt +2 -0
  36. rechnomat-0.1.0/src/rechnomat.egg-info/requires.txt +12 -0
  37. rechnomat-0.1.0/src/rechnomat.egg-info/top_level.txt +1 -0
  38. rechnomat-0.1.0/tests/test_add_invoice_command.py +112 -0
  39. rechnomat-0.1.0/tests/test_formatting.py +46 -0
  40. rechnomat-0.1.0/tests/test_init_command.py +64 -0
  41. rechnomat-0.1.0/tests/test_invoice_calc.py +86 -0
  42. rechnomat-0.1.0/tests/test_invoice_html.py +255 -0
  43. rechnomat-0.1.0/tests/test_invoice_numbering.py +64 -0
  44. rechnomat-0.1.0/tests/test_invoice_pdf.py +201 -0
  45. rechnomat-0.1.0/tests/test_letter_address.py +48 -0
  46. rechnomat-0.1.0/tests/test_model.py +165 -0
  47. rechnomat-0.1.0/tests/test_render_invoice_command.py +107 -0
  48. rechnomat-0.1.0/tests/test_yaml_io.py +17 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Haberey
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.
@@ -0,0 +1 @@
1
+ recursive-include src/rechnomat/resources *
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: rechnomat
3
+ Version: 0.1.0
4
+ Summary: Create invoices compliant with German E-Rechnung laws
5
+ Author-email: Sebastian Haberey <sebastian@haberey.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sebastianhaberey/rechnomat
8
+ Project-URL: Repository, https://github.com/sebastianhaberey/rechnomat
9
+ Project-URL: Bug Tracker, https://github.com/sebastianhaberey/rechnomat/issues
10
+ Project-URL: Changelog, https://github.com/sebastianhaberey/rechnomat/blob/main/CHANGELOG.md
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Environment :: Console
17
+ Requires-Python: >=3.14
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: click==8.4.2
21
+ Requires-Dist: cloup==3.1.0
22
+ Requires-Dist: jinja2==3.1.6
23
+ Requires-Dist: playwright==1.62.0
24
+ Requires-Dist: pydantic==2.13.4
25
+ Requires-Dist: pypdf==6.16.1
26
+ Requires-Dist: pyyaml==6.0.3
27
+ Requires-Dist: rich==15.0.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest==9.1.1; extra == "dev"
30
+ Requires-Dist: ruff==0.16.3; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # rechnomat
34
+
35
+ Create invoices compliant with German E-Rechnung laws.
36
+
37
+ ## Installation
38
+
39
+ ```
40
+ git clone https://github.com/sebastianhaberey/rechnomat.git
41
+ cd rechnomat
42
+ pip install .
43
+ playwright install chromium
44
+ ```
45
+
46
+ Invoice PDFs are rendered by driving a headless Chromium instance (via
47
+ [Playwright](https://playwright.dev/python/)) over HTML/CSS templates. `playwright install
48
+ chromium` downloads the browser binary Playwright needs; it's a one-time, per-machine step, not a
49
+ pip package, and is required before `rechnomat render-invoice` will work.
50
+
51
+ ### Getting started
52
+
53
+ The tool reads `backgrounds/`, `customers/`, `invoices/`, `seller/` and `templates/` from the
54
+ current directory, not from the installed package. Before running `rechnomat render-invoice`, run
55
+ `rechnomat init` inside whichever directory you run `rechnomat` from; it copies these five
56
+ folders, pre-filled with example data, from `src/rechnomat/resources/` into the current directory.
57
+ Folders that already exist are left untouched, so re-running `init` later only fills in whatever
58
+ is still missing; you are notified whether each folder was copied or skipped.
59
+
60
+ ### System dependencies for headless Chromium (Linux only)
61
+
62
+ On Linux, Chromium also needs a handful of shared libraries that aren't installed by default.
63
+ macOS needs no extra step here - skip this section.
64
+
65
+ **Debian/Ubuntu:**
66
+
67
+ ```
68
+ sudo playwright install-deps chromium
69
+ ```
70
+
71
+ **Fedora:** `playwright install-deps` only knows how to drive `apt-get`, so on Fedora it fails
72
+ outright (`spawn apt-get ENOENT`). Install the equivalent packages with `dnf` instead:
73
+
74
+ ```
75
+ sudo dnf install -y atk at-spi2-atk at-spi2-core cups-libs libXcomposite libXdamage libXfixes
76
+ ```
77
+
78
+ If `rechnomat render-invoice` fails with "Host system is missing dependencies to run browsers",
79
+ run `ldd` against the downloaded Chromium binary to see exactly which `.so` is missing:
80
+
81
+ ```
82
+ ldd ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome | grep "not found"
83
+ ```
84
+
85
+ ## Usage
86
+
87
+ ```
88
+ rechnomat --help
89
+ ```
90
+
91
+ ## Development
92
+
93
+ For an editable install with test and lint tooling:
94
+
95
+ ```
96
+ pip install -e '.[dev]'
97
+ playwright install chromium
98
+ ```
99
+
100
+ Tests use the example data and templates bundled in `src/rechnomat/resources/` directly.
101
+
102
+ ```
103
+ pytest
104
+ ruff check .
105
+ ruff format --check .
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT - see [LICENSE](LICENSE).
@@ -0,0 +1,78 @@
1
+ # rechnomat
2
+
3
+ Create invoices compliant with German E-Rechnung laws.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ git clone https://github.com/sebastianhaberey/rechnomat.git
9
+ cd rechnomat
10
+ pip install .
11
+ playwright install chromium
12
+ ```
13
+
14
+ Invoice PDFs are rendered by driving a headless Chromium instance (via
15
+ [Playwright](https://playwright.dev/python/)) over HTML/CSS templates. `playwright install
16
+ chromium` downloads the browser binary Playwright needs; it's a one-time, per-machine step, not a
17
+ pip package, and is required before `rechnomat render-invoice` will work.
18
+
19
+ ### Getting started
20
+
21
+ The tool reads `backgrounds/`, `customers/`, `invoices/`, `seller/` and `templates/` from the
22
+ current directory, not from the installed package. Before running `rechnomat render-invoice`, run
23
+ `rechnomat init` inside whichever directory you run `rechnomat` from; it copies these five
24
+ folders, pre-filled with example data, from `src/rechnomat/resources/` into the current directory.
25
+ Folders that already exist are left untouched, so re-running `init` later only fills in whatever
26
+ is still missing; you are notified whether each folder was copied or skipped.
27
+
28
+ ### System dependencies for headless Chromium (Linux only)
29
+
30
+ On Linux, Chromium also needs a handful of shared libraries that aren't installed by default.
31
+ macOS needs no extra step here - skip this section.
32
+
33
+ **Debian/Ubuntu:**
34
+
35
+ ```
36
+ sudo playwright install-deps chromium
37
+ ```
38
+
39
+ **Fedora:** `playwright install-deps` only knows how to drive `apt-get`, so on Fedora it fails
40
+ outright (`spawn apt-get ENOENT`). Install the equivalent packages with `dnf` instead:
41
+
42
+ ```
43
+ sudo dnf install -y atk at-spi2-atk at-spi2-core cups-libs libXcomposite libXdamage libXfixes
44
+ ```
45
+
46
+ If `rechnomat render-invoice` fails with "Host system is missing dependencies to run browsers",
47
+ run `ldd` against the downloaded Chromium binary to see exactly which `.so` is missing:
48
+
49
+ ```
50
+ ldd ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome | grep "not found"
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```
56
+ rechnomat --help
57
+ ```
58
+
59
+ ## Development
60
+
61
+ For an editable install with test and lint tooling:
62
+
63
+ ```
64
+ pip install -e '.[dev]'
65
+ playwright install chromium
66
+ ```
67
+
68
+ Tests use the example data and templates bundled in `src/rechnomat/resources/` directly.
69
+
70
+ ```
71
+ pytest
72
+ ruff check .
73
+ ruff format --check .
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT - see [LICENSE](LICENSE).
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rechnomat"
7
+ version = "0.1.0"
8
+ description = "Create invoices compliant with German E-Rechnung laws"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+
12
+ license = "MIT"
13
+
14
+ authors = [
15
+ { name = "Sebastian Haberey", email = "sebastian@haberey.com" }
16
+ ]
17
+
18
+ dependencies = [
19
+ "click==8.4.2",
20
+ "cloup==3.1.0",
21
+ "jinja2==3.1.6",
22
+ "playwright==1.62.0",
23
+ "pydantic==2.13.4",
24
+ "pypdf==6.16.1",
25
+ "pyyaml==6.0.3",
26
+ "rich==15.0.0",
27
+ ]
28
+
29
+ classifiers = [
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3 :: Only",
32
+ "Programming Language :: Python :: 3.14",
33
+ "Operating System :: MacOS :: MacOS X",
34
+ "Operating System :: POSIX :: Linux",
35
+ "Environment :: Console",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/sebastianhaberey/rechnomat"
40
+ Repository = "https://github.com/sebastianhaberey/rechnomat"
41
+ "Bug Tracker" = "https://github.com/sebastianhaberey/rechnomat/issues"
42
+ Changelog = "https://github.com/sebastianhaberey/rechnomat/blob/main/CHANGELOG.md"
43
+
44
+ [project.scripts]
45
+ rechnomat = "rechnomat.__main__:main"
46
+
47
+ [project.optional-dependencies]
48
+ dev = [
49
+ "pytest==9.1.1",
50
+ "ruff==0.16.3",
51
+ ]
52
+
53
+ [tool.setuptools]
54
+ package-dir = { "" = "src" }
55
+ include-package-data = true
56
+
57
+ [tool.setuptools.packages.find]
58
+ where = ["src"]
59
+
60
+ [tool.setuptools.package-data]
61
+ rechnomat = ["resources/**/*"]
62
+
63
+ [tool.ruff]
64
+ line-length = 120
65
+ target-version = "py314"
66
+ src = ["src", "tools"]
67
+
68
+ [tool.ruff.lint]
69
+ select = [
70
+ "E", # Pycodestyle (basic errors)
71
+ "F", # Pyflakes (unused imports, etc.)
72
+ "W", # Warnings
73
+ "I", # Import sorting (isort behavior)
74
+ "UP", # pyupgrade: e.g., prefer dict[str, int] over Dict[str, int]
75
+ ]
76
+
77
+ [tool.ruff.format]
78
+ quote-style = "double"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,34 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import rechnomat.cli
5
+ from rechnomat import ui
6
+ from rechnomat.cli import cli
7
+
8
+
9
+ def main():
10
+ args = sys.argv[1:]
11
+ if render_newlines(args):
12
+ ui.newline()
13
+ try:
14
+ rechnomat_executable = Path(sys.argv[0])
15
+ cli.main(args, obj={"rechnomat_executable": rechnomat_executable})
16
+ except Exception as e:
17
+ if rechnomat.cli.DEBUG_MODE:
18
+ ui.stacktrace("Error while running rechnomat")
19
+ else:
20
+ ui.exception(e)
21
+ finally:
22
+ if render_newlines(args):
23
+ ui.newline()
24
+
25
+
26
+ def render_newlines(args: list[str]) -> bool:
27
+ """Quick and dirty hack to evaluate if the two framing newlines should be rendered"""
28
+ if "--headless" in args:
29
+ return False # don't output anything in headless mode to avoid system mails
30
+ return True
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,127 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import cloup
5
+ from click import help_option, pass_obj, version_option
6
+ from cloup import HelpFormatter, HelpTheme, Section, argument, group, option, pass_context
7
+
8
+ from rechnomat.command.add_invoice import AddInvoiceCommand
9
+ from rechnomat.command.info import InfoCommand
10
+ from rechnomat.command.init import InitCommand
11
+ from rechnomat.command.render_invoice import RenderInvoiceCommand
12
+ from rechnomat.model import Context, Paths
13
+ from rechnomat.theme import StyleId, theme
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ STYLES = theme().styles_cloup
18
+
19
+ CONTEXT_SETTINGS = cloup.Context.settings(
20
+ formatter_settings=HelpFormatter.settings(
21
+ theme=HelpTheme(
22
+ invoked_command=STYLES[StyleId.SECONDARY],
23
+ heading=STYLES[StyleId.HEADER],
24
+ col1=STYLES[StyleId.PRIMARY],
25
+ ),
26
+ width=120,
27
+ max_width=120, # default is too low (80), but too high will yield really long help text lines
28
+ ),
29
+ )
30
+
31
+ SECTION_MAIN = Section("Main commands")
32
+ SECTION_UTILITY = Section("Utility commands")
33
+
34
+ # TODO SH find a better way to pass debug flag to exception handling
35
+ DEBUG_MODE: bool = False
36
+
37
+
38
+ @group(help="Rechnomat - create invoices compliant with German E-Rechnung laws", context_settings=CONTEXT_SETTINGS)
39
+ @help_option(help="Show this page")
40
+ @version_option(prog_name="Rechnomat", help="Show version information", message="%(prog)s version %(version)s")
41
+ @option(
42
+ "--debug",
43
+ envvar="EASYBORG_DEBUG",
44
+ is_flag=True,
45
+ help="Enable debug mode",
46
+ )
47
+ @pass_context
48
+ def cli(
49
+ ctx: cloup.Context,
50
+ debug: bool,
51
+ ) -> None:
52
+ # first, set DEBUG_MODE flag to enable stacktraces
53
+ global DEBUG_MODE
54
+ DEBUG_MODE = debug
55
+
56
+ ctx.ensure_object(dict)
57
+
58
+ paths = Paths(root=Path.cwd(), output_dir=Path.cwd())
59
+ rechnomat_executable = ctx.obj.pop("rechnomat_executable", None)
60
+
61
+ ctx.obj["context"] = Context(
62
+ debug=debug,
63
+ rechnomat_executable=rechnomat_executable,
64
+ paths=paths,
65
+ )
66
+
67
+
68
+ @cli.command(section=SECTION_MAIN)
69
+ @option(
70
+ "-o",
71
+ "--overwrite",
72
+ is_flag=True,
73
+ help="Replace existing directories instead of skipping them",
74
+ )
75
+ @help_option(help="Show this message")
76
+ @pass_obj
77
+ def init(obj, overwrite):
78
+ """
79
+ Copy the bundled example customers, invoices, seller and templates into the current directory
80
+ """
81
+ command = InitCommand(overwrite=overwrite)
82
+ command.run(obj["context"])
83
+
84
+
85
+ @cli.command(section=SECTION_MAIN, name="add-invoice")
86
+ @argument(
87
+ "customer-name",
88
+ required=False,
89
+ help="Customer to create the invoice for, references a Customer file by its filename stem (defaults to "
90
+ "whichever customer is on the most recent invoice)",
91
+ )
92
+ @help_option(help="Show this message")
93
+ @pass_obj
94
+ def add_invoice(obj, customer_name):
95
+ """
96
+ Add a new invoice, copying the customer's most recent invoice, or the most recent invoice
97
+ overall, or the bundled example invoice - whichever is found first
98
+ """
99
+ command = AddInvoiceCommand(customer_name=customer_name)
100
+ command.run(obj["context"])
101
+
102
+
103
+ @cli.command(section=SECTION_MAIN, name="render-invoice")
104
+ @argument(
105
+ "invoice-number",
106
+ required=False,
107
+ help="Invoice number to render (defaults to the highest-numbered invoice in invoices/)",
108
+ )
109
+ @help_option(help="Show this message")
110
+ @pass_obj
111
+ def render_invoice(obj, invoice_number):
112
+ """
113
+ Render an invoice as a PDF
114
+ """
115
+ command = RenderInvoiceCommand(invoice_number=invoice_number)
116
+ command.run(obj["context"])
117
+
118
+
119
+ @cli.command(section=SECTION_UTILITY)
120
+ @help_option(help="Show this message")
121
+ @pass_obj
122
+ def info(obj):
123
+ """
124
+ Show current configuration, paths etc.
125
+ """
126
+ command = InfoCommand()
127
+ command.run(obj["context"])
@@ -0,0 +1,80 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+
6
+ from rechnomat import ui
7
+ from rechnomat.command.init import RESOURCES_DIR
8
+ from rechnomat.invoice_numbering import (
9
+ find_highest_invoice_number,
10
+ find_highest_matching_invoice_number,
11
+ increment_invoice_number,
12
+ )
13
+ from rechnomat.model import Context
14
+
15
+ EXAMPLE_INVOICES_DIR = RESOURCES_DIR / "invoices"
16
+
17
+ _CUSTOMER_FIELD_PATTERN = re.compile(r'^(customer:\s*)"[^"]*"', re.MULTILINE)
18
+
19
+
20
+ class AddInvoiceCommand:
21
+ def __init__(self, *, customer_name: str | None = None) -> None:
22
+ super().__init__()
23
+ self.customer_name = customer_name
24
+
25
+ def run(self, context: Context) -> None:
26
+ paths = context.paths
27
+
28
+ if self.customer_name is not None:
29
+ customer_file = paths.customer_file(self.customer_name)
30
+ if not customer_file.exists():
31
+ raise RuntimeError(f"Customer file not found: {customer_file}")
32
+
33
+ invoices_dir = paths.invoices_dir
34
+ customer_invoice_number = (
35
+ find_highest_matching_invoice_number(invoices_dir, self._belongs_to_customer)
36
+ if self.customer_name is not None
37
+ else None
38
+ )
39
+ highest_invoice_number = find_highest_invoice_number(invoices_dir)
40
+
41
+ if customer_invoice_number is not None:
42
+ source_file = paths.invoice_file(customer_invoice_number)
43
+ rewrite_customer = False
44
+ elif highest_invoice_number is not None:
45
+ source_file = paths.invoice_file(highest_invoice_number)
46
+ rewrite_customer = self.customer_name is not None
47
+ else:
48
+ example_number = find_highest_invoice_number(EXAMPLE_INVOICES_DIR)
49
+ if example_number is None:
50
+ raise RuntimeError(f"No example invoice found in: {EXAMPLE_INVOICES_DIR}")
51
+ source_file = EXAMPLE_INVOICES_DIR / f"{example_number}.yml"
52
+ rewrite_customer = self.customer_name is not None
53
+
54
+ if not source_file.exists():
55
+ raise RuntimeError(f"Invoice file not found: {source_file}")
56
+
57
+ new_invoice_number = (
58
+ increment_invoice_number(highest_invoice_number) if highest_invoice_number is not None else source_file.stem
59
+ )
60
+
61
+ target_file = paths.invoice_file(new_invoice_number)
62
+
63
+ content = source_file.read_text(encoding="utf-8")
64
+ if rewrite_customer:
65
+ content = self._with_customer(content)
66
+
67
+ target_file.parent.mkdir(parents=True, exist_ok=True)
68
+ target_file.write_text(content, encoding="utf-8")
69
+
70
+ ui.success("Added invoice", str(target_file))
71
+
72
+ def _belongs_to_customer(self, invoice_file: Path) -> bool:
73
+ data = yaml.safe_load(invoice_file.read_text(encoding="utf-8"))
74
+ return isinstance(data, dict) and data.get("customer") == self.customer_name
75
+
76
+ def _with_customer(self, content: str) -> str:
77
+ new_content, count = _CUSTOMER_FIELD_PATTERN.subn(rf'\1"{self.customer_name}"', content, count=1)
78
+ if count == 0:
79
+ raise RuntimeError("Source invoice file has no 'customer' field to replace")
80
+ return new_content
@@ -0,0 +1,25 @@
1
+ from rechnomat import ui
2
+ from rechnomat.model import Context
3
+ from rechnomat.theme import StyleId, theme
4
+
5
+ STYLES = theme().styles
6
+
7
+
8
+ class InfoCommand:
9
+ def __init__(self) -> None:
10
+ super().__init__()
11
+
12
+ def run(self, context: Context) -> None:
13
+ rows = [
14
+ ("Executable", context.rechnomat_executable),
15
+ ("Customers directory", context.paths.customers_dir),
16
+ ("Invoices directory", context.paths.invoices_dir),
17
+ ("Seller directory", context.paths.seller_dir),
18
+ ("Templates directory", context.paths.templates_dir),
19
+ ("Output directory", context.paths.output_dir),
20
+ ]
21
+ ui.header("Context", first=True)
22
+ ui.table(
23
+ rows,
24
+ column_colors=(STYLES[StyleId.PRIMARY], None),
25
+ )
@@ -0,0 +1,36 @@
1
+ import importlib.resources
2
+ import shutil
3
+
4
+ from rechnomat import ui
5
+ from rechnomat.model import Context
6
+
7
+ RESOURCES_DIR = importlib.resources.files("rechnomat") / "resources"
8
+
9
+
10
+ class InitCommand:
11
+ def __init__(self, overwrite: bool = False) -> None:
12
+ self.overwrite = overwrite
13
+
14
+ def run(self, context: Context) -> None:
15
+ paths = context.paths
16
+ resources = (
17
+ ("backgrounds", paths.backgrounds_dir),
18
+ ("customers", paths.customers_dir),
19
+ ("invoices", paths.invoices_dir),
20
+ ("seller", paths.seller_dir),
21
+ ("templates", paths.templates_dir),
22
+ )
23
+
24
+ for name, target_dir in resources:
25
+ replaced = False
26
+
27
+ if target_dir.exists():
28
+ if not self.overwrite:
29
+ ui.warn("Skipped", f"{target_dir} (already exists)")
30
+ continue
31
+
32
+ shutil.rmtree(target_dir)
33
+ replaced = True
34
+
35
+ shutil.copytree(RESOURCES_DIR / name, target_dir)
36
+ ui.success("Replaced" if replaced else "Copied", str(target_dir))
@@ -0,0 +1,56 @@
1
+ from rechnomat import ui
2
+ from rechnomat.invoice_numbering import find_highest_invoice_number
3
+ from rechnomat.invoice_pdf import render_invoice_pdf
4
+ from rechnomat.model import Context, Customer, Invoice, Seller
5
+ from rechnomat.yaml_io import load_model
6
+
7
+
8
+ class RenderInvoiceCommand:
9
+ def __init__(self, *, invoice_number: str | None = None) -> None:
10
+ super().__init__()
11
+ self.invoice_number = invoice_number
12
+
13
+ def run(self, context: Context) -> None:
14
+ invoices_dir = context.paths.invoices_dir
15
+
16
+ invoice_number = self.invoice_number or find_highest_invoice_number(invoices_dir)
17
+ if invoice_number is None:
18
+ raise RuntimeError(f"No invoices found in: {invoices_dir}")
19
+
20
+ invoice_file = context.paths.invoice_file(invoice_number)
21
+ if not invoice_file.exists():
22
+ raise RuntimeError(f"Invoice file not found: {invoice_file}")
23
+ invoice = load_model(invoice_file, Invoice)
24
+
25
+ customer_file = context.paths.customer_file(invoice.customer)
26
+ if not customer_file.exists():
27
+ raise RuntimeError(f"Customer file not found: {customer_file}")
28
+ customer = load_model(customer_file, Customer)
29
+
30
+ seller_file = context.paths.seller_file
31
+ if not seller_file.exists():
32
+ raise RuntimeError(f"Seller file not found: {seller_file}")
33
+ seller = load_model(seller_file, Seller)
34
+
35
+ template_dir = context.paths.template_dir(invoice.layout.template)
36
+ if not template_dir.exists():
37
+ raise RuntimeError(f"Template directory not found: {template_dir}")
38
+
39
+ background_path = None
40
+ if invoice.layout.background is not None:
41
+ background_path = context.paths.background_file(invoice.layout.background)
42
+ if not background_path.exists():
43
+ raise RuntimeError(f"Background file not found: {background_path}")
44
+
45
+ target_file = context.paths.output_dir / f"{invoice_number}.pdf"
46
+ render_invoice_pdf(
47
+ invoice=invoice,
48
+ invoice_number=invoice_number,
49
+ customer=customer,
50
+ seller=seller,
51
+ output_path=target_file,
52
+ template_dir=template_dir,
53
+ background_path=background_path,
54
+ )
55
+
56
+ ui.success("Rendered invoice PDF", str(target_file))