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.
Files changed (44) hide show
  1. getafix/__init__.py +0 -0
  2. getafix/cli.py +146 -0
  3. getafix/errors.py +31 -0
  4. getafix/pdf.py +119 -0
  5. getafix/py.typed +0 -0
  6. getafix/report/__init__.py +104 -0
  7. getafix/report/_types.py +80 -0
  8. getafix/report/accounting.py +171 -0
  9. getafix/report/agreement.py +82 -0
  10. getafix/report/delivery.py +72 -0
  11. getafix/report/document.py +85 -0
  12. getafix/report/element.py +39 -0
  13. getafix/report/line.py +141 -0
  14. getafix/report/party.py +173 -0
  15. getafix/report/references.py +50 -0
  16. getafix/report/settlement.py +215 -0
  17. getafix/report/trade.py +111 -0
  18. getafix/report/types.py +99 -0
  19. getafix/rules/__init__.py +13 -0
  20. getafix/rules/_types.py +102 -0
  21. getafix/rules/accounting.py +269 -0
  22. getafix/rules/extended.py +647 -0
  23. getafix/rules/line.py +75 -0
  24. getafix/rules/party.py +147 -0
  25. getafix/rules/settlement.py +328 -0
  26. getafix/rules/trade.py +1204 -0
  27. getafix/schema/__init__.py +5 -0
  28. getafix/schema/_numeric.py +32 -0
  29. getafix/schema/accounting.py +535 -0
  30. getafix/schema/agreement.py +139 -0
  31. getafix/schema/delivery.py +111 -0
  32. getafix/schema/document.py +290 -0
  33. getafix/schema/element.py +483 -0
  34. getafix/schema/line.py +950 -0
  35. getafix/schema/party.py +999 -0
  36. getafix/schema/references.py +386 -0
  37. getafix/schema/settlement.py +805 -0
  38. getafix/schema/trade.py +224 -0
  39. getafix/schema/types.py +1871 -0
  40. getafix-0.1.0.dist-info/METADATA +284 -0
  41. getafix-0.1.0.dist-info/RECORD +44 -0
  42. getafix-0.1.0.dist-info/WHEEL +4 -0
  43. getafix-0.1.0.dist-info/entry_points.txt +3 -0
  44. 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)
@@ -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
+ )