tieout 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.
tieout/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """tieout: an open-source reconciliation engine.
2
+
3
+ from tieout import load_spec, reconcile
4
+ from tieout.connectors import read_source
5
+
6
+ spec = load_spec("recon.yaml")
7
+ bank = read_source("bank", "statement.xml", spec.sources["bank"])
8
+ ledger = read_source("ledger", "ledger.csv", spec.sources["ledger"])
9
+ result = reconcile([bank, ledger], spec)
10
+ """
11
+
12
+ from tieout.engine import ENGINE_VERSION as __version__
13
+ from tieout.engine import reconcile
14
+ from tieout.errors import ConnectorError, InputError, MoneyError, SpecError, TieoutError
15
+ from tieout.records import Record, Source
16
+ from tieout.result import Break, BreakReason, Match, Result, RunInfo
17
+ from tieout.spec import Rule, RuleKind, Spec, load_spec, parse_spec
18
+
19
+ __all__ = [
20
+ "Break",
21
+ "BreakReason",
22
+ "ConnectorError",
23
+ "InputError",
24
+ "Match",
25
+ "MoneyError",
26
+ "Record",
27
+ "Result",
28
+ "Rule",
29
+ "RuleKind",
30
+ "RunInfo",
31
+ "Source",
32
+ "Spec",
33
+ "SpecError",
34
+ "TieoutError",
35
+ "__version__",
36
+ "load_spec",
37
+ "parse_spec",
38
+ "reconcile",
39
+ ]
tieout/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """Command-line interface for tieout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from tieout.connectors import read_source, validate_spec
11
+ from tieout.engine import ENGINE_VERSION, reconcile
12
+ from tieout.errors import TieoutError
13
+ from tieout.report import format_summary, to_json, write_breaks_csv, write_matches_csv
14
+ from tieout.spec import load_spec
15
+
16
+
17
+ class _CliError(click.ClickException):
18
+ """Configuration or input error; exits with code 2 to stay distinct from breaks (1)."""
19
+
20
+ exit_code = 2
21
+
22
+
23
+ @click.group()
24
+ @click.version_option(ENGINE_VERSION, prog_name="tieout")
25
+ def main() -> None:
26
+ """Reconcile money records across banks, payment providers, ledgers, and invoices."""
27
+
28
+
29
+ @main.command()
30
+ @click.argument("spec_path", type=click.Path(dir_okay=False, path_type=Path))
31
+ def validate(spec_path: Path) -> None:
32
+ """Validate a YAML reconciliation spec and its source options."""
33
+ try:
34
+ spec = load_spec(spec_path)
35
+ validate_spec(spec)
36
+ except TieoutError as exc:
37
+ raise _CliError(str(exc)) from exc
38
+
39
+ click.echo(
40
+ f"Valid spec: {len(spec.sources)} source(s), {len(spec.rules)} rule(s) ({spec.digest()})"
41
+ )
42
+
43
+
44
+ @main.command()
45
+ @click.argument("spec_path", type=click.Path(dir_okay=False, path_type=Path))
46
+ @click.option(
47
+ "--input",
48
+ "-i",
49
+ "inputs",
50
+ multiple=True,
51
+ required=True,
52
+ metavar="NAME=PATH",
53
+ help="Bind a declared source name to an input file (repeat for each source).",
54
+ )
55
+ @click.option(
56
+ "--output-dir",
57
+ "-o",
58
+ type=click.Path(file_okay=False, path_type=Path),
59
+ default=None,
60
+ help="Write result.json, matches.csv, and breaks.csv into this directory.",
61
+ )
62
+ @click.option("--json", "as_json", is_flag=True, help="Print full result JSON to stdout.")
63
+ @click.option(
64
+ "--fail-on-breaks",
65
+ is_flag=True,
66
+ help="Exit with status 1 if any breaks remain after running all rules.",
67
+ )
68
+ def run(
69
+ spec_path: Path,
70
+ inputs: tuple[str, ...],
71
+ output_dir: Path | None,
72
+ as_json: bool,
73
+ fail_on_breaks: bool,
74
+ ) -> None:
75
+ """Run a reconciliation spec against input files."""
76
+ bindings = _parse_inputs(inputs)
77
+ try:
78
+ spec = load_spec(spec_path)
79
+ validate_spec(spec)
80
+ for name in bindings:
81
+ if name not in spec.sources:
82
+ declared = ", ".join(sorted(spec.sources)) or "(none)"
83
+ raise _CliError(
84
+ f"input {name!r} is not declared in spec.sources (declared: {declared})"
85
+ )
86
+ sources = [read_source(name, path, spec.sources[name]) for name, path in bindings.items()]
87
+ result = reconcile(sources, spec)
88
+ except TieoutError as exc:
89
+ raise _CliError(str(exc)) from exc
90
+
91
+ if output_dir is not None:
92
+ output_dir.mkdir(parents=True, exist_ok=True)
93
+ (output_dir / "result.json").write_text(to_json(result) + "\n", encoding="utf-8")
94
+ write_matches_csv(result, output_dir / "matches.csv")
95
+ write_breaks_csv(result, output_dir / "breaks.csv")
96
+
97
+ if as_json:
98
+ click.echo(to_json(result))
99
+ if output_dir is not None:
100
+ click.echo(f"Wrote reports to {output_dir}", err=True)
101
+ else:
102
+ click.echo(format_summary(result))
103
+ if output_dir is not None:
104
+ click.echo(f"\nWrote reports to {output_dir}")
105
+
106
+ if fail_on_breaks and result.breaks:
107
+ sys.exit(1)
108
+
109
+
110
+ def _parse_inputs(raw_inputs: tuple[str, ...]) -> dict[str, Path]:
111
+ bindings: dict[str, Path] = {}
112
+ for item in raw_inputs:
113
+ name, sep, path_str = item.partition("=")
114
+ name = name.strip()
115
+ path_str = path_str.strip()
116
+ if not sep or not name or not path_str:
117
+ raise _CliError(f"invalid --input {item!r}: expected NAME=PATH")
118
+ if name in bindings:
119
+ raise _CliError(f"duplicate --input for source {name!r}")
120
+ bindings[name] = Path(path_str)
121
+ return bindings
tieout/conditions.py ADDED
@@ -0,0 +1,226 @@
1
+ """How a rule judges a candidate pairing, and how it explains the verdict.
2
+
3
+ A pairing is a group of left records against a group of right records. For a
4
+ one-to-one rule both groups hold one record; for grouping rules one side holds
5
+ several. Every check is defined over groups so all rule kinds share one meaning:
6
+
7
+ - amount: right total must equal the expected total (left total, or its negation
8
+ for ``sign: opposite``) within the tolerance;
9
+ - date: *every* right date minus *every* left date must fall in the window;
10
+ - references: the condition must hold for *every* left/right record pair.
11
+
12
+ Containment checks (``left_in_right``/``right_in_left``) match whole tokens only:
13
+ the needle must start and end where the haystack has a token boundary (a
14
+ separator, or a switch between letters and digits). ``"INV-0042"`` is found in
15
+ ``"Payment for inv 0042"``, but ``"7712"`` is not found in ``"Payment #77712"``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections.abc import Iterable, Sequence
22
+ from dataclasses import dataclass
23
+ from functools import lru_cache
24
+
25
+ from tieout.money import format_amount
26
+ from tieout.records import Record
27
+ from tieout.spec import ReferenceCondition, Rule
28
+
29
+ _NON_ALPHANUMERIC = re.compile(r"[^0-9A-Z]+")
30
+ _TOKEN = re.compile(r"[A-Z]+|[0-9]+")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class DateSpan:
35
+ """Earliest and latest date of a group of records, as proleptic ordinals."""
36
+
37
+ first: int
38
+ last: int
39
+
40
+
41
+ def date_span(records: Sequence[Record], rule: Rule) -> DateSpan | None:
42
+ """Date span used by the rule's date check, or None if any record lacks the date.
43
+
44
+ With a date condition, only the field it names counts. Without one, dates only
45
+ rank otherwise equal candidates, so a record's value date is used if it has
46
+ one and its booking date if not.
47
+ """
48
+ ordinals = []
49
+ for record in records:
50
+ if rule.date is not None:
51
+ value = record.date_of(rule.date.field)
52
+ else:
53
+ value = record.value_date or record.booking_date
54
+ if value is None:
55
+ return None
56
+ ordinals.append(value.toordinal())
57
+ return DateSpan(min(ordinals), max(ordinals))
58
+
59
+
60
+ def dates_fit(rule: Rule, left: DateSpan, right: DateSpan) -> bool:
61
+ if rule.date is None:
62
+ return True
63
+ return (
64
+ right.first - left.last >= rule.date.min_days
65
+ and right.last - left.first <= rule.date.max_days
66
+ )
67
+
68
+
69
+ def date_distance(left: DateSpan, right: DateSpan) -> int:
70
+ """Largest absolute gap between any left and any right date; used to rank candidates."""
71
+ return max(abs(right.last - left.first), abs(left.last - right.first))
72
+
73
+
74
+ def expected_right_total(rule: Rule, left_total: int) -> int:
75
+ return left_total if rule.amount.sign == "same" else -left_total
76
+
77
+
78
+ def amount_difference(rule: Rule, left_total: int, right_total: int) -> int:
79
+ return right_total - expected_right_total(rule, left_total)
80
+
81
+
82
+ @lru_cache(maxsize=524288)
83
+ def normalize_reference(text: str) -> str:
84
+ return _NON_ALPHANUMERIC.sub("", text.upper())
85
+
86
+
87
+ @lru_cache(maxsize=262144)
88
+ def _token_boundaries(text: str) -> frozenset[int]:
89
+ """Offsets in the normalised text where a token starts or ends."""
90
+ boundaries = {0}
91
+ offset = 0
92
+ for token in _TOKEN.findall(text.upper()):
93
+ offset += len(token)
94
+ boundaries.add(offset)
95
+ return frozenset(boundaries)
96
+
97
+
98
+ def contains_token_sequence(haystack: str, needle: str) -> bool:
99
+ """Whether normalised ``needle`` occurs in ``haystack`` aligned to token boundaries."""
100
+ normalized_haystack = normalize_reference(haystack)
101
+ normalized_needle = normalize_reference(needle)
102
+ if not normalized_needle:
103
+ return False
104
+ boundaries = _token_boundaries(haystack)
105
+ start = normalized_haystack.find(normalized_needle)
106
+ while start != -1:
107
+ if start in boundaries and start + len(normalized_needle) in boundaries:
108
+ return True
109
+ start = normalized_haystack.find(normalized_needle, start + 1)
110
+ return False
111
+
112
+
113
+ def reference_holds(condition: ReferenceCondition, left: Record, right: Record) -> bool:
114
+ raw_left, raw_right = left.value_of(condition.left), right.value_of(condition.right)
115
+ left_value, right_value = normalize_reference(raw_left), normalize_reference(raw_right)
116
+ if not left_value or not right_value:
117
+ return False # an absent reference never counts as agreement
118
+ if condition.op == "equals":
119
+ return left_value == right_value
120
+ needle, haystack = (
121
+ (raw_left, raw_right)
122
+ if condition.op == "left_in_right"
123
+ else (
124
+ raw_right,
125
+ raw_left,
126
+ )
127
+ )
128
+ if len(normalize_reference(needle)) < condition.min_length:
129
+ return False
130
+ return contains_token_sequence(haystack, needle)
131
+
132
+
133
+ def references_hold(rule: Rule, lefts: Sequence[Record], rights: Sequence[Record]) -> bool:
134
+ return all(
135
+ reference_holds(condition, left, right)
136
+ for condition in rule.references
137
+ for left in lefts
138
+ for right in rights
139
+ )
140
+
141
+
142
+ def explain(
143
+ rule: Rule,
144
+ lefts: Sequence[Record],
145
+ rights: Sequence[Record],
146
+ *,
147
+ grouping_note: str | None = None,
148
+ ) -> tuple[str, ...]:
149
+ """Plain-language account of why this pairing satisfies the rule."""
150
+ currency = lefts[0].currency
151
+ left_total = sum(record.amount for record in lefts)
152
+ right_total = sum(record.amount for record in rights)
153
+ difference = amount_difference(rule, left_total, right_total)
154
+
155
+ lines = [
156
+ f"rule {rule.id} ({rule.kind.value}): "
157
+ f"{_count(len(lefts), 'left record')} against {_count(len(rights), 'right record')}",
158
+ _explain_amount(rule, left_total, right_total, difference, currency),
159
+ ]
160
+ if rule.date is not None:
161
+ lines.append(_explain_dates(rule, lefts, rights))
162
+ lines.extend(_explain_reference(condition, lefts, rights) for condition in rule.references)
163
+ if grouping_note:
164
+ lines.append(grouping_note)
165
+ return tuple(lines)
166
+
167
+
168
+ _OP_WORDS = {
169
+ "equals": "equals",
170
+ "left_in_right": "is contained in",
171
+ "right_in_left": "contains",
172
+ }
173
+
174
+
175
+ def _explain_amount(
176
+ rule: Rule, left_total: int, right_total: int, difference: int, currency: str
177
+ ) -> str:
178
+ sign_note = " (opposite signs)" if rule.amount.sign == "opposite" else ""
179
+ totals = (
180
+ f"amount: left {format_amount(left_total, currency)} {currency}, "
181
+ f"right {format_amount(right_total, currency)} {currency}{sign_note}"
182
+ )
183
+ if difference == 0:
184
+ return f"{totals}, exact"
185
+ return (
186
+ f"{totals}, difference {format_amount(difference, currency)} {currency} "
187
+ f"within tolerance {format_amount(rule.amount.tolerance, currency)}"
188
+ )
189
+
190
+
191
+ def _explain_reference(
192
+ condition: ReferenceCondition, lefts: Sequence[Record], rights: Sequence[Record]
193
+ ) -> str:
194
+ left_values = _distinct(record.value_of(condition.left) for record in lefts)
195
+ right_values = _distinct(record.value_of(condition.right) for record in rights)
196
+ return (
197
+ f"reference: left.{condition.left} {_quote(left_values)} {_OP_WORDS[condition.op]} "
198
+ f"right.{condition.right} {_quote(right_values)}"
199
+ )
200
+
201
+
202
+ def _distinct(values: Iterable[str]) -> list[str]:
203
+ return sorted(set(values))
204
+
205
+
206
+ def _quote(values: list[str]) -> str:
207
+ shown = ", ".join(repr(value) for value in values[:3])
208
+ more = f" and {len(values) - 3} more" if len(values) > 3 else ""
209
+ return shown + more
210
+
211
+
212
+ def _explain_dates(rule: Rule, lefts: Sequence[Record], rights: Sequence[Record]) -> str:
213
+ assert rule.date is not None
214
+ left, right = date_span(lefts, rule), date_span(rights, rule)
215
+ assert left is not None
216
+ assert right is not None
217
+ low, high = right.first - left.last, right.last - left.first
218
+ gap = f"{low} days" if low == high else f"{low} to {high} days"
219
+ return (
220
+ f"date: right {rule.date.field} minus left is {gap} "
221
+ f"(allowed {rule.date.min_days} to {rule.date.max_days})"
222
+ )
223
+
224
+
225
+ def _count(n: int, noun: str) -> str:
226
+ return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
@@ -0,0 +1,122 @@
1
+ """Source connectors: parse external files into canonical `Source` objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from tieout.connectors.camt import CamtOptions, parse_camt
13
+ from tieout.connectors.csv import CsvOptions, parse_csv
14
+ from tieout.connectors.stripe import StripeOptions, parse_stripe
15
+ from tieout.errors import ConnectorError, SpecError
16
+ from tieout.records import Exclusion, Record, Source
17
+ from tieout.spec import RuleKind, SourceSpec, Spec
18
+
19
+ _Parser = Callable[[str, Path, bytes, dict[str, Any]], tuple[list[Record], list[Exclusion]]]
20
+
21
+ _CONNECTORS: dict[str, tuple[_Parser, type[CamtOptions | CsvOptions | StripeOptions]]] = {
22
+ "camt": (parse_camt, CamtOptions),
23
+ "csv": (parse_csv, CsvOptions),
24
+ "stripe": (parse_stripe, StripeOptions),
25
+ }
26
+
27
+
28
+ def validate_source_spec(spec: SourceSpec) -> frozenset[str]:
29
+ """Verify a source's connector format and options without reading a file.
30
+
31
+ Returns the field names records from that source can carry.
32
+ """
33
+ entry = _CONNECTORS.get(spec.format)
34
+ if entry is None:
35
+ supported = ", ".join(sorted(_CONNECTORS))
36
+ raise SpecError(f"unknown source format {spec.format!r} (expected one of: {supported})")
37
+ _, option_model = entry
38
+ try:
39
+ options = option_model.model_validate(spec.options)
40
+ except ValidationError as exc:
41
+ lines = [
42
+ f"{'.'.join(str(p) for p in err['loc']) or 'options'}: {err['msg']}"
43
+ for err in exc.errors()
44
+ ]
45
+ raise SpecError(f"invalid {spec.format} options:\n " + "\n ".join(lines)) from exc
46
+ return options.fields()
47
+
48
+
49
+ def validate_spec(spec: Spec) -> None:
50
+ """Check every source's options, and that every field a rule names can exist.
51
+
52
+ A misspelt field (``payout_idd``) would otherwise match nothing, silently: an
53
+ absent reference never counts as agreement and an absent group key forms no
54
+ group. Only declared sources are checked.
55
+ """
56
+ available = {name: validate_source_spec(source) for name, source in spec.sources.items()}
57
+ problems: list[str] = []
58
+ for rule in spec.rules:
59
+ wanted: list[tuple[str, str, str]] = [] # (side, source, field)
60
+ for condition in rule.references:
61
+ wanted.append(("left", rule.left, condition.left))
62
+ wanted.append(("right", rule.right, condition.right))
63
+ if rule.date is not None:
64
+ wanted.append(("left", rule.left, rule.date.field))
65
+ wanted.append(("right", rule.right, rule.date.field))
66
+ if rule.group is not None and rule.group.by is not None:
67
+ side, source = (
68
+ ("right", rule.right) if rule.kind is RuleKind.ONE_TO_MANY else ("left", rule.left)
69
+ )
70
+ wanted.append((side, source, rule.group.by))
71
+ for side, source, field in wanted:
72
+ fields = available.get(source)
73
+ if fields is not None and field not in fields:
74
+ problems.append(
75
+ f"rule {rule.id!r}: {side} source {source!r} has no field {field!r} "
76
+ f"(available: {', '.join(sorted(fields))})"
77
+ )
78
+ if problems:
79
+ raise SpecError("invalid spec:\n " + "\n ".join(dict.fromkeys(problems)))
80
+
81
+
82
+ def read_source(
83
+ name: str,
84
+ path: str | Path,
85
+ spec: SourceSpec | str,
86
+ **options: Any,
87
+ ) -> Source:
88
+ """Read a file into a `Source` using the named connector and record its byte digest."""
89
+ if isinstance(spec, str):
90
+ fmt = spec
91
+ merged_options: dict[str, Any] = dict(options)
92
+ else:
93
+ fmt = spec.format
94
+ merged_options = {**spec.options, **options}
95
+
96
+ entry = _CONNECTORS.get(fmt)
97
+ if entry is None:
98
+ supported = ", ".join(sorted(_CONNECTORS))
99
+ raise SpecError(f"unknown source format {fmt!r} (expected one of: {supported})")
100
+ parser, _ = entry
101
+
102
+ file_path = Path(path)
103
+ try:
104
+ raw_bytes = file_path.read_bytes()
105
+ except OSError as exc:
106
+ detail = exc.strerror or str(exc)
107
+ raise ConnectorError(f"cannot read file: {detail}", path=str(file_path)) from exc
108
+
109
+ digest = f"sha256:{hashlib.sha256(raw_bytes).hexdigest()}"
110
+ try:
111
+ records, excluded = parser(name, file_path, raw_bytes, merged_options)
112
+ except ValidationError as exc:
113
+ lines = [
114
+ f"{'.'.join(str(p) for p in err['loc']) or 'options'}: {err['msg']}"
115
+ for err in exc.errors()
116
+ ]
117
+ raise SpecError(f"invalid {fmt} options:\n " + "\n ".join(lines)) from exc
118
+
119
+ return Source(name=name, records=tuple(records), digest=digest, excluded=tuple(excluded))
120
+
121
+
122
+ __all__ = ["read_source", "validate_source_spec", "validate_spec"]