getafix 0.1.0__py3-none-any.whl
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.
- getafix/__init__.py +0 -0
- getafix/cli.py +146 -0
- getafix/errors.py +31 -0
- getafix/pdf.py +119 -0
- getafix/py.typed +0 -0
- getafix/report/__init__.py +104 -0
- getafix/report/_types.py +80 -0
- getafix/report/accounting.py +171 -0
- getafix/report/agreement.py +82 -0
- getafix/report/delivery.py +72 -0
- getafix/report/document.py +85 -0
- getafix/report/element.py +39 -0
- getafix/report/line.py +141 -0
- getafix/report/party.py +173 -0
- getafix/report/references.py +50 -0
- getafix/report/settlement.py +215 -0
- getafix/report/trade.py +111 -0
- getafix/report/types.py +99 -0
- getafix/rules/__init__.py +13 -0
- getafix/rules/_types.py +102 -0
- getafix/rules/accounting.py +269 -0
- getafix/rules/extended.py +647 -0
- getafix/rules/line.py +75 -0
- getafix/rules/party.py +147 -0
- getafix/rules/settlement.py +328 -0
- getafix/rules/trade.py +1204 -0
- getafix/schema/__init__.py +5 -0
- getafix/schema/_numeric.py +32 -0
- getafix/schema/accounting.py +535 -0
- getafix/schema/agreement.py +139 -0
- getafix/schema/delivery.py +111 -0
- getafix/schema/document.py +290 -0
- getafix/schema/element.py +483 -0
- getafix/schema/line.py +950 -0
- getafix/schema/party.py +999 -0
- getafix/schema/references.py +386 -0
- getafix/schema/settlement.py +805 -0
- getafix/schema/trade.py +224 -0
- getafix/schema/types.py +1871 -0
- getafix-0.1.0.dist-info/METADATA +284 -0
- getafix-0.1.0.dist-info/RECORD +44 -0
- getafix-0.1.0.dist-info/WHEEL +4 -0
- getafix-0.1.0.dist-info/entry_points.txt +3 -0
- getafix-0.1.0.dist-info/licenses/LICENSE +202 -0
getafix/__init__.py
ADDED
|
File without changes
|
getafix/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Command-line entry point for the ``getafix`` console script.
|
|
2
|
+
|
|
3
|
+
Reads a Cross-Industry-Invoice XML file — or a Factur-X / ZUGFeRD PDF
|
|
4
|
+
that has one embedded — parses it into a
|
|
5
|
+
:class:`getafix.schema.Document`, runs the business-rule validators
|
|
6
|
+
and prints both the invoice and the validation result as a rich
|
|
7
|
+
console report.
|
|
8
|
+
|
|
9
|
+
Requires the optional ``cli`` extra (pulls in ``lxml`` and ``rich``).
|
|
10
|
+
PDF input additionally needs the ``pdf`` extra (pypdf)::
|
|
11
|
+
|
|
12
|
+
pip install 'getafix[cli,pdf]'
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
|
|
16
|
+
* ``0`` — XML parsed cleanly and passed every validator.
|
|
17
|
+
* ``1`` — XML parsed but at least one validation rule fired
|
|
18
|
+
(or the document tree could not be parsed as a CII invoice, or no
|
|
19
|
+
Factur-X XML was found in the supplied PDF).
|
|
20
|
+
* ``2`` — usage / IO / missing dependency error.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import cast
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="getafix",
|
|
34
|
+
description=(
|
|
35
|
+
"Pretty-print a ZUGFeRD / Factur-X Cross-Industry-Invoice "
|
|
36
|
+
"XML file and report business-rule violations."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
_ = parser.add_argument(
|
|
40
|
+
"source",
|
|
41
|
+
type=Path,
|
|
42
|
+
help=(
|
|
43
|
+
"Path to a CII XML file (typically ``factur-x.xml``) or a "
|
|
44
|
+
"Factur-X / ZUGFeRD PDF with one embedded."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
_ = parser.add_argument(
|
|
48
|
+
"--no-validate",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Skip running the BR-* business-rule validators.",
|
|
51
|
+
)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _looks_like_pdf(path: Path) -> bool:
|
|
56
|
+
"""``True`` if ``path`` starts with the PDF magic header."""
|
|
57
|
+
try:
|
|
58
|
+
with path.open("rb") as fp:
|
|
59
|
+
return fp.read(5) == b"%PDF-"
|
|
60
|
+
except OSError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
parser = _build_parser()
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
import lxml.etree as etree
|
|
70
|
+
from rich.console import Console
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
_ = sys.stderr.write(
|
|
73
|
+
f"getafix CLI needs the optional 'cli' extra ({exc.name}): "
|
|
74
|
+
f"{exc.msg}.\nInstall with: pip install 'getafix[cli]'\n"
|
|
75
|
+
)
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
from getafix.report import render_invoice, render_validation_errors
|
|
79
|
+
from getafix.schema.document import Document
|
|
80
|
+
|
|
81
|
+
out = Console()
|
|
82
|
+
err = Console(stderr=True)
|
|
83
|
+
|
|
84
|
+
source = cast("Path", args.source)
|
|
85
|
+
no_validate = cast("bool", args.no_validate)
|
|
86
|
+
if not source.is_file():
|
|
87
|
+
err.print(f"[red]Could not read {source}: not a file[/red]")
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
if source.suffix.lower() == ".pdf" or _looks_like_pdf(source):
|
|
91
|
+
try:
|
|
92
|
+
from getafix.pdf import extract_xml
|
|
93
|
+
except ImportError:
|
|
94
|
+
err.print(
|
|
95
|
+
"[red]PDF input needs the optional 'pdf' dependency.[/red]\n"
|
|
96
|
+
"[red]Install with: pip install 'getafix[pdf]'[/red]"
|
|
97
|
+
)
|
|
98
|
+
return 2
|
|
99
|
+
try:
|
|
100
|
+
xml_payload = extract_xml(source)
|
|
101
|
+
except (OSError, ValueError) as exc:
|
|
102
|
+
err.print(f"[red]Could not read PDF {source}: {exc}[/red]")
|
|
103
|
+
return 1
|
|
104
|
+
if xml_payload is None:
|
|
105
|
+
err.print(
|
|
106
|
+
f"[red]No Factur-X / ZUGFeRD XML found in {source} "
|
|
107
|
+
"(looked for the standard attachment names).[/red]"
|
|
108
|
+
)
|
|
109
|
+
return 1
|
|
110
|
+
try:
|
|
111
|
+
tree = etree.ElementTree(etree.fromstring(xml_payload))
|
|
112
|
+
except etree.XMLSyntaxError as exc:
|
|
113
|
+
err.print(f"[red]Embedded XML in {source} is malformed: {exc}[/red]")
|
|
114
|
+
return 1
|
|
115
|
+
else:
|
|
116
|
+
try:
|
|
117
|
+
tree = etree.parse(str(source))
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
err.print(f"[red]Could not read {source}: {exc}[/red]")
|
|
120
|
+
return 2
|
|
121
|
+
except etree.XMLSyntaxError as exc:
|
|
122
|
+
err.print(f"[red]XML syntax error in {source}: {exc}[/red]")
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
doc = Document.from_xml(tree.getroot())
|
|
127
|
+
except (ValueError, TypeError, AssertionError) as exc:
|
|
128
|
+
err.print(
|
|
129
|
+
f"[red]Could not parse {source} as a CII invoice: "
|
|
130
|
+
f"{type(exc).__name__}: {exc}[/red]"
|
|
131
|
+
)
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
render_invoice(doc, console=out)
|
|
135
|
+
|
|
136
|
+
if no_validate:
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
profile = doc.context.guideline.id
|
|
140
|
+
errors = doc.validate_internal(profile)
|
|
141
|
+
render_validation_errors(errors, console=out)
|
|
142
|
+
return 1 if errors else 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__": # pragma: no cover
|
|
146
|
+
raise SystemExit(main())
|
getafix/errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class ValidationError(ValueError):
|
|
2
|
+
"""A single business-rule violation.
|
|
3
|
+
|
|
4
|
+
``code`` is the rule identifier (``BR-CO-15`` etc.) and ``message``
|
|
5
|
+
a human-readable explanation. Subclassing ``ValueError`` keeps
|
|
6
|
+
backwards-compat with code that catches ``ValueError`` broadly, but
|
|
7
|
+
the public ``Document.validate`` entry point raises the plural
|
|
8
|
+
:class:`ValidationErrors` instead so callers can see every
|
|
9
|
+
violation in one pass.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, code: str, message: str):
|
|
13
|
+
super().__init__(f"{code}: {message}")
|
|
14
|
+
self.code: str = code
|
|
15
|
+
self.message: str = message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ValidationErrors(ValueError):
|
|
19
|
+
"""Aggregate exception holding every ValidationError from a single
|
|
20
|
+
``Document.validate`` pass.
|
|
21
|
+
|
|
22
|
+
``errors`` is the list, in document order. Callers can check for a
|
|
23
|
+
specific rule with::
|
|
24
|
+
|
|
25
|
+
assert any(e.code == "BR-CO-15" for e in exc.errors)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, errors: list[ValidationError]):
|
|
29
|
+
joined = "; ".join(f"{e.code}: {e.message}" for e in errors)
|
|
30
|
+
super().__init__(joined)
|
|
31
|
+
self.errors: list[ValidationError] = errors
|
getafix/pdf.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Embed and extract Factur-X / ZUGFeRD XML in PDF documents.
|
|
2
|
+
|
|
3
|
+
Importing this module requires the optional ``pypdf`` dependency::
|
|
4
|
+
|
|
5
|
+
pip install 'getafix[pdf]'
|
|
6
|
+
|
|
7
|
+
Two operations:
|
|
8
|
+
|
|
9
|
+
* :func:`attach_xml` — embed an XML file in a PDF under a configurable
|
|
10
|
+
attachment name. Defaults to ``factur-x.xml`` (the Factur-X 1.x /
|
|
11
|
+
ZUGFeRD 2.x convention); pass ``"zugferd-invoice.xml"`` for the
|
|
12
|
+
legacy ZUGFeRD 1.0 layout, ``"xrechnung.xml"`` for XRechnung, etc.
|
|
13
|
+
* :func:`extract_xml` — return the bytes of the first embedded file
|
|
14
|
+
whose name matches one of the standard Factur-X / ZUGFeRD /
|
|
15
|
+
XRechnung / Order-X filenames.
|
|
16
|
+
|
|
17
|
+
Caveat: :func:`attach_xml` produces a valid PDF with a generic embedded
|
|
18
|
+
file, but does *not* upgrade the document to PDF/A-3 — the formal
|
|
19
|
+
compliance requirement for Factur-X and ZUGFeRD invoices. Use a
|
|
20
|
+
dedicated PDF/A-3 converter when full conformance is needed; this
|
|
21
|
+
module only handles the file-attachment step.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from io import BytesIO
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from pypdf import PdfReader, PdfWriter
|
|
31
|
+
|
|
32
|
+
STANDARD_LOCATIONS: tuple[str, ...] = (
|
|
33
|
+
"factur-x.xml", # Factur-X 1.x / ZUGFeRD 2.x
|
|
34
|
+
"ZUGFeRD-invoice.xml", # ZUGFeRD 1.0 (legacy)
|
|
35
|
+
"zugferd-invoice.xml", # ZUGFeRD 1.0 alt casing
|
|
36
|
+
"xrechnung.xml", # XRechnung
|
|
37
|
+
"order-x.xml", # Order-X
|
|
38
|
+
)
|
|
39
|
+
"""Conventional filenames getafix will search for, in priority order."""
|
|
40
|
+
|
|
41
|
+
DEFAULT_ATTACHMENT_NAME: str = STANDARD_LOCATIONS[0]
|
|
42
|
+
"""Name used by :func:`attach_xml` when the caller doesn't override it."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def attach_xml(
|
|
46
|
+
pdf_in: Path | bytes,
|
|
47
|
+
xml: Path | bytes,
|
|
48
|
+
*,
|
|
49
|
+
pdf_out: Path | None = None,
|
|
50
|
+
attachment_name: str = DEFAULT_ATTACHMENT_NAME,
|
|
51
|
+
metadata: Mapping[str, str] | None = None,
|
|
52
|
+
) -> Path | bytes:
|
|
53
|
+
"""Embed ``xml`` in ``pdf_in`` as an embedded file and write the result.
|
|
54
|
+
|
|
55
|
+
``pdf_in`` may be a :class:`~pathlib.Path` to read from, or the raw
|
|
56
|
+
bytes of a PDF. ``xml`` may likewise be a :class:`~pathlib.Path` to
|
|
57
|
+
read from, or raw bytes to embed verbatim.
|
|
58
|
+
|
|
59
|
+
``pdf_out`` selects where the result goes. When given, the PDF is
|
|
60
|
+
written there and that :class:`~pathlib.Path` is returned. When
|
|
61
|
+
omitted, a :class:`~pathlib.Path` ``pdf_in`` is rewritten in place
|
|
62
|
+
(and returned); a bytes ``pdf_in`` causes the result to be returned
|
|
63
|
+
as bytes.
|
|
64
|
+
|
|
65
|
+
The attachment is stored under ``attachment_name`` in the PDF's
|
|
66
|
+
embedded-files name tree. The default matches the Factur-X 1.x /
|
|
67
|
+
ZUGFeRD 2.x convention; override for ZUGFeRD 1.0 or XRechnung.
|
|
68
|
+
|
|
69
|
+
``metadata``, when given, extends the PDF's document information
|
|
70
|
+
dictionary (e.g. ``{"/Title": "Invoice 42"}``); existing entries are
|
|
71
|
+
kept unless a given key overrides them.
|
|
72
|
+
|
|
73
|
+
Returns the path the PDF was written to, or the result bytes when
|
|
74
|
+
``pdf_in`` is bytes and no ``pdf_out`` is given.
|
|
75
|
+
"""
|
|
76
|
+
xml_bytes = xml.read_bytes() if isinstance(xml, Path) else xml
|
|
77
|
+
clone_from = BytesIO(pdf_in) if isinstance(pdf_in, bytes) else str(pdf_in)
|
|
78
|
+
writer = PdfWriter(clone_from=clone_from)
|
|
79
|
+
_ = writer.add_attachment(attachment_name, xml_bytes)
|
|
80
|
+
if metadata:
|
|
81
|
+
writer.add_metadata(dict(metadata))
|
|
82
|
+
target = pdf_out if pdf_out is not None else pdf_in
|
|
83
|
+
if isinstance(target, bytes):
|
|
84
|
+
buffer = BytesIO()
|
|
85
|
+
_ = writer.write(buffer)
|
|
86
|
+
return buffer.getvalue()
|
|
87
|
+
with target.open("wb") as fp:
|
|
88
|
+
_ = writer.write(fp)
|
|
89
|
+
return target
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_xml(
|
|
93
|
+
pdf: Path, *, candidates: Sequence[str] = STANDARD_LOCATIONS
|
|
94
|
+
) -> bytes | None:
|
|
95
|
+
"""Return the bytes of the first embedded file matching ``candidates``.
|
|
96
|
+
|
|
97
|
+
Names are compared case-insensitively, so e.g. ``factur-x.xml``,
|
|
98
|
+
``Factur-X.xml`` and ``FACTUR-X.XML`` are all accepted. Search
|
|
99
|
+
order follows ``candidates``; the first hit wins.
|
|
100
|
+
|
|
101
|
+
Returns ``None`` when the PDF has no embedded files or none of
|
|
102
|
+
them matches a candidate.
|
|
103
|
+
"""
|
|
104
|
+
reader = PdfReader(str(pdf))
|
|
105
|
+
attachments = reader.attachments
|
|
106
|
+
if not attachments:
|
|
107
|
+
return None
|
|
108
|
+
by_lowercase = {name.lower(): name for name in attachments}
|
|
109
|
+
for candidate in candidates:
|
|
110
|
+
original = by_lowercase.get(candidate.lower())
|
|
111
|
+
if original is None:
|
|
112
|
+
continue
|
|
113
|
+
# ``pypdf`` returns a list of byte payloads per attachment name
|
|
114
|
+
# (an attachment name can be re-used inside one PDF — rare in
|
|
115
|
+
# the Factur-X corpus but allowed). Return the first payload.
|
|
116
|
+
payload = attachments[original]
|
|
117
|
+
if payload:
|
|
118
|
+
return payload[0]
|
|
119
|
+
return None
|
getafix/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Rich console report of a parsed Cross-Industry-Invoice :class:`Document`.
|
|
2
|
+
|
|
3
|
+
Importing this package requires the optional ``rich`` dependency::
|
|
4
|
+
|
|
5
|
+
pip install 'getafix[cli]'
|
|
6
|
+
|
|
7
|
+
The package mirrors :mod:`getafix.schema`: every schema module has a
|
|
8
|
+
report counterpart that knows how to render the elements defined there
|
|
9
|
+
(``document`` → Invoice panel, ``party`` → Seller / Buyer panels,
|
|
10
|
+
``trade`` → line-items table, ``accounting`` → VAT breakdown / totals,
|
|
11
|
+
``settlement`` → payment / prepayment sections, …). Shared primitives
|
|
12
|
+
live in :mod:`getafix.report._types`; code-formatting helpers in
|
|
13
|
+
:mod:`getafix.report.types`. (``schema/_numeric.py`` has no counterpart
|
|
14
|
+
— it is a rounding helper, not a renderable element.)
|
|
15
|
+
|
|
16
|
+
Two public entry points:
|
|
17
|
+
|
|
18
|
+
* :func:`render_invoice` — pretty-print the document (header, parties,
|
|
19
|
+
lines, VAT breakdown, totals, payment block).
|
|
20
|
+
* :func:`render_validation_errors` — pretty-print a list of
|
|
21
|
+
:class:`getafix.schema.element.ValidationError` from
|
|
22
|
+
``Document.validate_internal``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
|
|
31
|
+
from getafix.report._types import SectionRenderer as SectionRenderer
|
|
32
|
+
from getafix.report.accounting import allowance_charge_panel, tax_table, totals_panel
|
|
33
|
+
from getafix.report.agreement import supporting_documents_panel
|
|
34
|
+
from getafix.report.delivery import delivery_panel
|
|
35
|
+
from getafix.report.document import header_panel
|
|
36
|
+
from getafix.report.element import render_validation_errors as render_validation_errors
|
|
37
|
+
from getafix.report.party import party_panel, tax_representative_panel
|
|
38
|
+
from getafix.report.settlement import (
|
|
39
|
+
advance_payments_panel,
|
|
40
|
+
logistics_charges_panel,
|
|
41
|
+
payment_panel,
|
|
42
|
+
)
|
|
43
|
+
from getafix.report.trade import lines_table
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from rich.console import Console
|
|
47
|
+
|
|
48
|
+
from getafix.schema.document import Document
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render_invoice(doc: Document, console: Console | None = None) -> None:
|
|
52
|
+
"""Print a structured, colourised report of ``doc`` to ``console``.
|
|
53
|
+
|
|
54
|
+
Sections are emitted top to bottom in reading order; each optional
|
|
55
|
+
section short-circuits to ``None`` when it has nothing to show, so a
|
|
56
|
+
bare MINIMUM invoice prints only the header, parties and totals.
|
|
57
|
+
"""
|
|
58
|
+
from rich.console import Console
|
|
59
|
+
|
|
60
|
+
console = console or Console()
|
|
61
|
+
settlement = doc.trade.settlement
|
|
62
|
+
currency = settlement.currency_code
|
|
63
|
+
|
|
64
|
+
console.print(header_panel(doc))
|
|
65
|
+
# Force a true 50/50 split: a 2-column grid with ``ratio=1``
|
|
66
|
+
# columns gives each party panel exactly half the terminal width.
|
|
67
|
+
# ``Columns(equal=True)`` only equalises widths when content fits,
|
|
68
|
+
# which truncates a long Seller block against the Buyer side.
|
|
69
|
+
parties = Table.grid(expand=True, padding=(0, 1))
|
|
70
|
+
parties.add_column(ratio=1)
|
|
71
|
+
parties.add_column(ratio=1)
|
|
72
|
+
parties.add_row(
|
|
73
|
+
party_panel("Seller", doc.trade.agreement.seller),
|
|
74
|
+
party_panel("Buyer", doc.trade.agreement.buyer),
|
|
75
|
+
)
|
|
76
|
+
console.print(parties)
|
|
77
|
+
tax_representative = tax_representative_panel(
|
|
78
|
+
doc.trade.agreement.seller_tax_representative_party
|
|
79
|
+
)
|
|
80
|
+
if tax_representative is not None:
|
|
81
|
+
console.print(tax_representative)
|
|
82
|
+
delivery = delivery_panel(doc.trade.delivery)
|
|
83
|
+
if delivery is not None:
|
|
84
|
+
console.print(delivery)
|
|
85
|
+
supporting = supporting_documents_panel(doc.trade.agreement)
|
|
86
|
+
if supporting is not None:
|
|
87
|
+
console.print(supporting)
|
|
88
|
+
if doc.trade.items:
|
|
89
|
+
console.print(lines_table(doc.trade, currency))
|
|
90
|
+
if settlement.trade_taxes:
|
|
91
|
+
console.print(tax_table(settlement))
|
|
92
|
+
allowance_charges = allowance_charge_panel(settlement)
|
|
93
|
+
if allowance_charges is not None:
|
|
94
|
+
console.print(allowance_charges)
|
|
95
|
+
logistics_charges = logistics_charges_panel(settlement)
|
|
96
|
+
if logistics_charges is not None:
|
|
97
|
+
console.print(logistics_charges)
|
|
98
|
+
console.print(totals_panel(settlement))
|
|
99
|
+
advance_payments = advance_payments_panel(settlement)
|
|
100
|
+
if advance_payments is not None:
|
|
101
|
+
console.print(advance_payments)
|
|
102
|
+
payment = payment_panel(settlement)
|
|
103
|
+
if payment is not None:
|
|
104
|
+
console.print(payment)
|
getafix/report/_types.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared rendering primitives used across :mod:`getafix.report`.
|
|
2
|
+
|
|
3
|
+
Mirrors :mod:`getafix.rules._types`: where the rules package defines a
|
|
4
|
+
:data:`~getafix.rules.Validator` shape plus a couple of validator
|
|
5
|
+
factories, the report package defines a :data:`SectionRenderer` shape
|
|
6
|
+
plus the two helpers that give every section its consistent look —
|
|
7
|
+
|
|
8
|
+
* :func:`described_panel` — wrap a panel body under a one-line, dim
|
|
9
|
+
description of *what the section means*;
|
|
10
|
+
* :func:`describe_table` — fold the same kind of description into the
|
|
11
|
+
table's title block as a dim subtitle line.
|
|
12
|
+
|
|
13
|
+
Keeping these in one place is what lets each ``report/<topic>.py``
|
|
14
|
+
focus purely on turning its schema elements into rows / cells while the
|
|
15
|
+
framing (titles, borders, descriptions) stays uniform.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from rich.console import Group, RenderableType
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from getafix.schema.element import Element
|
|
28
|
+
|
|
29
|
+
type SectionRenderer[T: Element] = Callable[[T], RenderableType | None]
|
|
30
|
+
"""Signature of a report-section renderer.
|
|
31
|
+
|
|
32
|
+
``T`` is the concrete :class:`~getafix.schema.element.Element` the
|
|
33
|
+
section is built from. The function returns a Rich renderable
|
|
34
|
+
(:class:`~rich.panel.Panel` / :class:`~rich.table.Table`) or ``None``
|
|
35
|
+
to signal "nothing to show" so the caller can skip an empty section
|
|
36
|
+
rather than printing an empty box.
|
|
37
|
+
|
|
38
|
+
Not every renderer fits the shape exactly — money-bearing sections also
|
|
39
|
+
take the document currency, and :func:`getafix.report.party.party_panel`
|
|
40
|
+
takes the party role — but the ``element -> renderable | None`` core is
|
|
41
|
+
the common contract.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Per-section descriptions render in this style: muted, so the data
|
|
45
|
+
# stays the focus while the one-liner explains what the box is.
|
|
46
|
+
_DESCRIPTION_STYLE = "dim italic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def described_panel(
|
|
50
|
+
body: RenderableType, *, title: str, description: str, border_style: str
|
|
51
|
+
) -> Panel:
|
|
52
|
+
"""Frame ``body`` in a titled panel with a dim description on top.
|
|
53
|
+
|
|
54
|
+
The description is a short, human sentence explaining what the
|
|
55
|
+
section means (e.g. "The supplier issuing the invoice."). It is
|
|
56
|
+
placed above the body so a reader skimming the report can orient
|
|
57
|
+
themselves before reading the data.
|
|
58
|
+
"""
|
|
59
|
+
content: RenderableType = (
|
|
60
|
+
Group(Text(description, style=_DESCRIPTION_STYLE), body)
|
|
61
|
+
if description
|
|
62
|
+
else body
|
|
63
|
+
)
|
|
64
|
+
return Panel(content, title=title, border_style=border_style)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def describe_table(table: Table, description: str) -> Table:
|
|
68
|
+
"""Fold ``description`` into the table title as a dim subtitle line.
|
|
69
|
+
|
|
70
|
+
The description sits directly under the title (above the header row),
|
|
71
|
+
so it reads as the table's own subtitle. A bottom caption was
|
|
72
|
+
avoided because, between two stacked sections, it reads like the
|
|
73
|
+
heading of the *next* table.
|
|
74
|
+
"""
|
|
75
|
+
name = "" if table.title is None else str(table.title)
|
|
76
|
+
table.title = Text.assemble((name, "bold"), "\n", (description, _DESCRIPTION_STYLE))
|
|
77
|
+
# Styles now live on the Text spans; drop the base title style so it
|
|
78
|
+
# doesn't bleed bold onto the dim subtitle line.
|
|
79
|
+
table.title_style = None
|
|
80
|
+
return table
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Rendering for the financial spine (:mod:`getafix.schema.accounting`).
|
|
2
|
+
|
|
3
|
+
Three sections, all driven off the header settlement:
|
|
4
|
+
|
|
5
|
+
* :func:`tax_table` — the VAT breakdown (BG-23), one row per category /
|
|
6
|
+
rate;
|
|
7
|
+
* :func:`allowance_charge_panel` — document-level allowances (BG-20) and
|
|
8
|
+
charges (BG-21);
|
|
9
|
+
* :func:`totals_panel` — the monetary summation (BG-22) down to the
|
|
10
|
+
amount due.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from getafix.report._types import describe_table, described_panel
|
|
21
|
+
from getafix.report.types import dim_paren, format_amount, format_vat
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from decimal import Decimal
|
|
25
|
+
|
|
26
|
+
from getafix.schema.accounting import ApplicableTradeTax
|
|
27
|
+
from getafix.schema.settlement import TradeSettlement
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tax_table(settlement: TradeSettlement) -> Table:
|
|
31
|
+
"""VAT breakdown (BG-23): taxable base and tax per category and rate."""
|
|
32
|
+
currency = settlement.currency_code
|
|
33
|
+
taxes = settlement.trade_taxes or []
|
|
34
|
+
# The tax-point column (BT-7 date / BT-8 code) is rare — only add it
|
|
35
|
+
# when at least one row carries it, so the common invoice stays narrow.
|
|
36
|
+
has_tax_point = any(
|
|
37
|
+
tax.tax_point_date is not None or tax.due_date_code is not None for tax in taxes
|
|
38
|
+
)
|
|
39
|
+
table = Table(
|
|
40
|
+
title="VAT breakdown (BG-23)",
|
|
41
|
+
title_style="bold",
|
|
42
|
+
header_style="bold",
|
|
43
|
+
border_style="blue",
|
|
44
|
+
expand=True,
|
|
45
|
+
)
|
|
46
|
+
table.add_column("Cat", no_wrap=True)
|
|
47
|
+
table.add_column("Rate", justify="right")
|
|
48
|
+
table.add_column(f"Basis [{currency}]", justify="right")
|
|
49
|
+
table.add_column(f"Tax [{currency}]", justify="right")
|
|
50
|
+
table.add_column("Exemption reason")
|
|
51
|
+
if has_tax_point:
|
|
52
|
+
table.add_column("Tax point (BT-7/8)", no_wrap=True)
|
|
53
|
+
for tax in taxes:
|
|
54
|
+
rate = tax.rate_applicable_percent
|
|
55
|
+
reason = tax.exemption_reason or ""
|
|
56
|
+
if tax.exemption_reason_code:
|
|
57
|
+
reason = f"{dim_paren(tax.exemption_reason_code)} {reason}".strip()
|
|
58
|
+
cells = [
|
|
59
|
+
tax.category_code.value,
|
|
60
|
+
f"{rate}%" if rate is not None else "-",
|
|
61
|
+
f"{tax.basis_amount}" if tax.basis_amount is not None else "-",
|
|
62
|
+
f"{tax.calculated_amount}" if tax.calculated_amount is not None else "-",
|
|
63
|
+
reason,
|
|
64
|
+
]
|
|
65
|
+
if has_tax_point:
|
|
66
|
+
cells.append(_tax_point_cell(tax))
|
|
67
|
+
table.add_row(*cells)
|
|
68
|
+
return describe_table(
|
|
69
|
+
table, "Taxable base and tax amount per VAT category and rate."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _tax_point_cell(tax: ApplicableTradeTax) -> str:
|
|
74
|
+
"""Tax point date (BT-7) or, failing that, the due-date code (BT-8)."""
|
|
75
|
+
if tax.tax_point_date is not None:
|
|
76
|
+
return tax.tax_point_date.isoformat()
|
|
77
|
+
if tax.due_date_code is not None:
|
|
78
|
+
return f"code {tax.due_date_code.value}"
|
|
79
|
+
return "-"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def allowance_charge_panel(settlement: TradeSettlement) -> Table | None:
|
|
83
|
+
"""Document-level allowances (BG-20) and charges (BG-21).
|
|
84
|
+
|
|
85
|
+
Each row shows the indicator label, reason, amount, optional VAT
|
|
86
|
+
category + rate, and an optional ``% * basis`` derivation. Returns
|
|
87
|
+
``None`` if no header-level allowance/charge is present.
|
|
88
|
+
"""
|
|
89
|
+
items = settlement.allowance_charge or []
|
|
90
|
+
if not items:
|
|
91
|
+
return None
|
|
92
|
+
currency = settlement.currency_code
|
|
93
|
+
table = Table(
|
|
94
|
+
title="Document-level allowances & charges (BG-20 / BG-21)",
|
|
95
|
+
title_style="bold",
|
|
96
|
+
header_style="bold",
|
|
97
|
+
border_style="blue",
|
|
98
|
+
expand=True,
|
|
99
|
+
)
|
|
100
|
+
table.add_column("Kind", no_wrap=True)
|
|
101
|
+
table.add_column("Reason")
|
|
102
|
+
table.add_column(f"Amount [{currency}]", justify="right")
|
|
103
|
+
table.add_column("Calc.", justify="right")
|
|
104
|
+
table.add_column("VAT", justify="right", no_wrap=True)
|
|
105
|
+
for ac in items:
|
|
106
|
+
kind = "[red]Charge[/red]" if ac.indicator else "[green]Allowance[/green]"
|
|
107
|
+
reason_bits = [ac.reason or ""]
|
|
108
|
+
if ac.reason_code:
|
|
109
|
+
reason_bits.append(dim_paren(ac.reason_code))
|
|
110
|
+
if ac.calculation_percent is not None and ac.basis_amount is not None:
|
|
111
|
+
calc = f"{ac.calculation_percent}% of {ac.basis_amount}"
|
|
112
|
+
elif ac.calculation_percent is not None:
|
|
113
|
+
calc = f"{ac.calculation_percent}%"
|
|
114
|
+
elif ac.basis_amount is not None:
|
|
115
|
+
calc = f"basis {ac.basis_amount}"
|
|
116
|
+
else:
|
|
117
|
+
calc = "-"
|
|
118
|
+
ctt = ac.category_trade_tax
|
|
119
|
+
vat = format_vat(ctt.category_code, ctt.rate_applicable_percent) if ctt else "-"
|
|
120
|
+
table.add_row(
|
|
121
|
+
kind,
|
|
122
|
+
" ".join(b for b in reason_bits if b),
|
|
123
|
+
f"{ac.actual_amount}",
|
|
124
|
+
calc,
|
|
125
|
+
vat,
|
|
126
|
+
)
|
|
127
|
+
return describe_table(
|
|
128
|
+
table, "Discounts and surcharges applied to the whole invoice."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def totals_panel(settlement: TradeSettlement) -> Panel:
|
|
133
|
+
"""Monetary summation (BG-22): line, tax and grand totals to amount due."""
|
|
134
|
+
summ = settlement.monetary_summation
|
|
135
|
+
currency = settlement.currency_code
|
|
136
|
+
grid = Table.grid(padding=(0, 2))
|
|
137
|
+
grid.add_column(style="bold")
|
|
138
|
+
grid.add_column(justify="right")
|
|
139
|
+
rows: list[tuple[str, Decimal | None]] = [
|
|
140
|
+
("Line total (BT-106)", summ.line_total),
|
|
141
|
+
("Allowances (BT-107)", summ.allowance_total),
|
|
142
|
+
("Charges (BT-108)", summ.charge_total),
|
|
143
|
+
("Tax basis (BT-109)", summ.tax_basis_total),
|
|
144
|
+
]
|
|
145
|
+
for label, value in rows:
|
|
146
|
+
if value is None:
|
|
147
|
+
continue
|
|
148
|
+
grid.add_row(label, format_amount(value, currency))
|
|
149
|
+
for tax in summ.tax_total or []:
|
|
150
|
+
grid.add_row(
|
|
151
|
+
f"Tax total ({tax.currency_id})", format_amount(tax.amount, tax.currency_id)
|
|
152
|
+
)
|
|
153
|
+
tail: list[tuple[str, Decimal | None]] = [
|
|
154
|
+
("Rounding (BT-114)", summ.rounding_amount),
|
|
155
|
+
("Grand total (BT-112)", summ.grand_total),
|
|
156
|
+
("Prepaid (BT-113)", summ.prepaid_total),
|
|
157
|
+
]
|
|
158
|
+
for label, value in tail:
|
|
159
|
+
if value is None:
|
|
160
|
+
continue
|
|
161
|
+
grid.add_row(label, format_amount(value, currency))
|
|
162
|
+
grid.add_row(
|
|
163
|
+
"[bold yellow]Amount due (BT-115)[/bold yellow]",
|
|
164
|
+
f"[bold yellow]{format_amount(summ.due_amount, currency)}[/bold yellow]",
|
|
165
|
+
)
|
|
166
|
+
return described_panel(
|
|
167
|
+
grid,
|
|
168
|
+
title="[bold]Totals[/bold]",
|
|
169
|
+
description="Net, tax and gross totals down to the amount due.",
|
|
170
|
+
border_style="yellow",
|
|
171
|
+
)
|