recskit 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.
recskit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duckboard
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.
recskit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: recskit
3
+ Version: 0.1.0
4
+ Summary: Reconcile a supplier or customer statement against your accounting ledger: match invoices, credit notes, and payments, flag mismatches and missing items, compare balances. No dependencies in the core -- works with any data source.
5
+ Author: Duckboard
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Duckboard/recskit
8
+ Project-URL: Buy me a coffee, https://ko-fi.com/duckboard
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: sage
18
+ Requires-Dist: sagekit>=0.1.0; extra == "sage"
19
+ Provides-Extra: parse-pdf
20
+ Requires-Dist: pdfplumber>=0.10; extra == "parse-pdf"
21
+ Requires-Dist: anthropic>=0.30; extra == "parse-pdf"
22
+ Dynamic: license-file
23
+
24
+ # recskit
25
+
26
+ Reconcile a supplier or customer statement against your own accounting
27
+ ledger: match invoices, credit notes, and payments; flag mismatches
28
+ and missing items on either side; compare balances. The core has
29
+ **zero dependencies** and doesn't care where your data came from --
30
+ CSV, a PDF you parsed by hand, Sage, Xero, QuickBooks, anything.
31
+
32
+ If this saves you time, consider [buying me a coffee](https://ko-fi.com/duckboard).
33
+
34
+ ## What problem this solves
35
+
36
+ Checking a supplier statement against your own ledger by eye is slow
37
+ and error-prone, especially once an account has more than a handful of
38
+ open items. Two things make it worse than it looks:
39
+
40
+ 1. **A tied-up total balance doesn't mean every line is right.** Two
41
+ errors can cancel out -- a mismatched invoice and a missing one can
42
+ add up to the same total as if nothing were wrong. recskit checks
43
+ every line, not just the bottom-line figure.
44
+ 2. **Reference matching is a trap.** A short reference like `991` can
45
+ turn up as a *substring* of an unrelated, older invoice's reference
46
+ (`INV-99910`) on a long-standing account. Match on substring first
47
+ and you'll silently pair the statement line with the wrong invoice,
48
+ reporting a confident-looking "mismatch" instead of correctly
49
+ reporting no match at all. recskit always tries an **exact**
50
+ reference match before ever falling back to substring matching.
51
+
52
+ ## Features
53
+
54
+ - Matches invoices/credit notes by reference (with support for
55
+ statements that print two references for the same line), payments
56
+ by amount first then reference
57
+ - Exact-match-first, substring-fallback matching order -- see above
58
+ - Configurable payment-terms due-date calculation (flat N days,
59
+ N days from the start of the following month, N days from end of
60
+ month, or end of the following month with no offset)
61
+ - Flags items in your ledger that never show up on the statement at
62
+ all (commonly an old item sitting in a brought-forward balance)
63
+ - Zero required dependencies in the core -- add the optional `sage`
64
+ extra only if you want the bundled Sage 50 adapter
65
+ - Two worked examples: pure CSV (no external system at all), and Sage
66
+ 50 via [sagekit](https://github.com/Duckboard/sagekit)
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install -r requirements.txt # core only, zero dependencies
72
+ pip install -e . # or, as an editable package
73
+ pip install -e ".[sage]" # + the optional Sage 50 adapter
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ ```python
79
+ from recskit import StatementItem, LedgerInvoice, LedgerPayment, reconcile_statement
80
+
81
+ items = [
82
+ StatementItem(type="invoice", ref="INV-100", amount=500.00),
83
+ StatementItem(type="invoice", ref="INV-101", amount=320.00),
84
+ StatementItem(type="payment", ref="bacs", amount=300.00),
85
+ ]
86
+
87
+ ledger_invoices = [
88
+ LedgerInvoice(ref="INV-100", amount=500.00, outstanding=500.00),
89
+ LedgerInvoice(ref="INV-101", amount=300.00, outstanding=300.00),
90
+ ]
91
+ ledger_payments = [LedgerPayment(ref="BACS-9001", amount=300.00)]
92
+
93
+ result = reconcile_statement(items, ledger_invoices, ledger_payments, statement_balance=800.00)
94
+
95
+ print(result.agreed, result.issue_count, result.balance_difference)
96
+ for r in result.mismatched_items + result.missing_items:
97
+ print(r.item.ref, r.status, r.note)
98
+ ```
99
+
100
+ ### Payment-terms due dates
101
+
102
+ ```python
103
+ from datetime import date
104
+ from recskit import calc_due_date
105
+
106
+ calc_due_date(date(2026, 5, 14), 30) # 30 days from invoice date
107
+ calc_due_date(date(2026, 5, 14), 29, "start_of_following_month") # 29 days from 1 June
108
+ calc_due_date(date(2026, 5, 3), 14, "end_of_month") # 14 days from 31 May
109
+ calc_due_date(date(2026, 5, 3), 0, "end_of_following_month") # 30 June, terms_days ignored
110
+ ```
111
+
112
+ ### Examples
113
+
114
+ ```bash
115
+ python examples/reconcile_csv.py # runs on the bundled sample CSVs
116
+ python examples/reconcile_csv.py statement.csv ledger.csv 1063.45 # your own files
117
+ python examples/reconcile_sage.py # requires the "sage" extra + a live Sage 50
118
+ ```
119
+
120
+ `reconcile_csv.py` needs no external system at all -- a good starting
121
+ point for wiring recskit up to whatever export your own accounting
122
+ software produces. `reconcile_sage.py` shows the same reconciliation
123
+ against a live Sage 50 purchase ledger account, using
124
+ `recskit.sage_adapter` to fetch the ledger side and
125
+ [sagekit](https://github.com/Duckboard/sagekit) for the ODBC
126
+ connection and column auto-detection.
127
+
128
+ ### Concept reference
129
+
130
+ | Concept | Meaning |
131
+ |---|---|
132
+ | `StatementItem` | One line from the statement you're checking (invoice, credit note, or payment) |
133
+ | `LedgerInvoice` / `LedgerPayment` | The equivalent transaction as it exists in your own ledger |
134
+ | `MatchResult.status` | `"ok"`, `"mismatch"` (found but wrong amount), or `"missing"` (no match at all) |
135
+ | `extra_in_ledger` | Ledger invoices never referenced by any statement line -- check these aren't being missed, or are simply old brought-forward items |
136
+ | `balance_difference` | `ledger_balance - statement_balance` |
137
+
138
+ ## Bring your own data source
139
+
140
+ The reconciliation logic never touches a database, file, or network --
141
+ you build `StatementItem` / `LedgerInvoice` / `LedgerPayment` lists
142
+ however suits your setup, and call `reconcile_statement()`. Options:
143
+
144
+ - Parse a PDF or CSV statement export yourself (see
145
+ `examples/reconcile_csv.py`)
146
+ - Pull the ledger side from Sage 50 with `recskit.sage_adapter` (see
147
+ `examples/reconcile_sage.py`)
148
+ - Write your own adapter for Xero, QuickBooks, or anything else --
149
+ `sage_adapter.py` is about 100 lines and a reasonable template to
150
+ copy
151
+
152
+ ## Testing
153
+
154
+ ```bash
155
+ pip install pytest
156
+ pytest
157
+ ```
158
+
159
+ The core test suite has no dependencies to mock -- it's all plain
160
+ Python objects in, plain Python objects out.
161
+
162
+ ## Troubleshooting
163
+
164
+ | Symptom | Likely cause | Fix |
165
+ |---|---|---|
166
+ | A statement line matches the wrong ledger invoice | Shouldn't happen -- recskit always tries an exact ref match before substring | If you see this, please open an issue with the two refs involved |
167
+ | Balance agrees but you still see mismatches/missing items | This is expected and correct -- see "What problem this solves" above | Investigate each flagged line; a tied total doesn't guarantee every line is right |
168
+ | Payment shows as missing despite being in the ledger | Amount differs by more than the tolerance, and the reference also doesn't appear as a substring | Widen `tolerance` in `match_payment()`, or check the payment was recorded correctly |
169
+ | `ImportError` on `sagekit` | The `sage` extra wasn't installed | `pip install -e ".[sage]"`, or install sagekit directly |
170
+
171
+ ## License
172
+
173
+ MIT -- see [LICENSE](LICENSE). Do whatever you like with this; a
174
+ credit or a [Ko-fi tip](https://ko-fi.com/duckboard) is appreciated
175
+ but never required.
@@ -0,0 +1,152 @@
1
+ # recskit
2
+
3
+ Reconcile a supplier or customer statement against your own accounting
4
+ ledger: match invoices, credit notes, and payments; flag mismatches
5
+ and missing items on either side; compare balances. The core has
6
+ **zero dependencies** and doesn't care where your data came from --
7
+ CSV, a PDF you parsed by hand, Sage, Xero, QuickBooks, anything.
8
+
9
+ If this saves you time, consider [buying me a coffee](https://ko-fi.com/duckboard).
10
+
11
+ ## What problem this solves
12
+
13
+ Checking a supplier statement against your own ledger by eye is slow
14
+ and error-prone, especially once an account has more than a handful of
15
+ open items. Two things make it worse than it looks:
16
+
17
+ 1. **A tied-up total balance doesn't mean every line is right.** Two
18
+ errors can cancel out -- a mismatched invoice and a missing one can
19
+ add up to the same total as if nothing were wrong. recskit checks
20
+ every line, not just the bottom-line figure.
21
+ 2. **Reference matching is a trap.** A short reference like `991` can
22
+ turn up as a *substring* of an unrelated, older invoice's reference
23
+ (`INV-99910`) on a long-standing account. Match on substring first
24
+ and you'll silently pair the statement line with the wrong invoice,
25
+ reporting a confident-looking "mismatch" instead of correctly
26
+ reporting no match at all. recskit always tries an **exact**
27
+ reference match before ever falling back to substring matching.
28
+
29
+ ## Features
30
+
31
+ - Matches invoices/credit notes by reference (with support for
32
+ statements that print two references for the same line), payments
33
+ by amount first then reference
34
+ - Exact-match-first, substring-fallback matching order -- see above
35
+ - Configurable payment-terms due-date calculation (flat N days,
36
+ N days from the start of the following month, N days from end of
37
+ month, or end of the following month with no offset)
38
+ - Flags items in your ledger that never show up on the statement at
39
+ all (commonly an old item sitting in a brought-forward balance)
40
+ - Zero required dependencies in the core -- add the optional `sage`
41
+ extra only if you want the bundled Sage 50 adapter
42
+ - Two worked examples: pure CSV (no external system at all), and Sage
43
+ 50 via [sagekit](https://github.com/Duckboard/sagekit)
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install -r requirements.txt # core only, zero dependencies
49
+ pip install -e . # or, as an editable package
50
+ pip install -e ".[sage]" # + the optional Sage 50 adapter
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```python
56
+ from recskit import StatementItem, LedgerInvoice, LedgerPayment, reconcile_statement
57
+
58
+ items = [
59
+ StatementItem(type="invoice", ref="INV-100", amount=500.00),
60
+ StatementItem(type="invoice", ref="INV-101", amount=320.00),
61
+ StatementItem(type="payment", ref="bacs", amount=300.00),
62
+ ]
63
+
64
+ ledger_invoices = [
65
+ LedgerInvoice(ref="INV-100", amount=500.00, outstanding=500.00),
66
+ LedgerInvoice(ref="INV-101", amount=300.00, outstanding=300.00),
67
+ ]
68
+ ledger_payments = [LedgerPayment(ref="BACS-9001", amount=300.00)]
69
+
70
+ result = reconcile_statement(items, ledger_invoices, ledger_payments, statement_balance=800.00)
71
+
72
+ print(result.agreed, result.issue_count, result.balance_difference)
73
+ for r in result.mismatched_items + result.missing_items:
74
+ print(r.item.ref, r.status, r.note)
75
+ ```
76
+
77
+ ### Payment-terms due dates
78
+
79
+ ```python
80
+ from datetime import date
81
+ from recskit import calc_due_date
82
+
83
+ calc_due_date(date(2026, 5, 14), 30) # 30 days from invoice date
84
+ calc_due_date(date(2026, 5, 14), 29, "start_of_following_month") # 29 days from 1 June
85
+ calc_due_date(date(2026, 5, 3), 14, "end_of_month") # 14 days from 31 May
86
+ calc_due_date(date(2026, 5, 3), 0, "end_of_following_month") # 30 June, terms_days ignored
87
+ ```
88
+
89
+ ### Examples
90
+
91
+ ```bash
92
+ python examples/reconcile_csv.py # runs on the bundled sample CSVs
93
+ python examples/reconcile_csv.py statement.csv ledger.csv 1063.45 # your own files
94
+ python examples/reconcile_sage.py # requires the "sage" extra + a live Sage 50
95
+ ```
96
+
97
+ `reconcile_csv.py` needs no external system at all -- a good starting
98
+ point for wiring recskit up to whatever export your own accounting
99
+ software produces. `reconcile_sage.py` shows the same reconciliation
100
+ against a live Sage 50 purchase ledger account, using
101
+ `recskit.sage_adapter` to fetch the ledger side and
102
+ [sagekit](https://github.com/Duckboard/sagekit) for the ODBC
103
+ connection and column auto-detection.
104
+
105
+ ### Concept reference
106
+
107
+ | Concept | Meaning |
108
+ |---|---|
109
+ | `StatementItem` | One line from the statement you're checking (invoice, credit note, or payment) |
110
+ | `LedgerInvoice` / `LedgerPayment` | The equivalent transaction as it exists in your own ledger |
111
+ | `MatchResult.status` | `"ok"`, `"mismatch"` (found but wrong amount), or `"missing"` (no match at all) |
112
+ | `extra_in_ledger` | Ledger invoices never referenced by any statement line -- check these aren't being missed, or are simply old brought-forward items |
113
+ | `balance_difference` | `ledger_balance - statement_balance` |
114
+
115
+ ## Bring your own data source
116
+
117
+ The reconciliation logic never touches a database, file, or network --
118
+ you build `StatementItem` / `LedgerInvoice` / `LedgerPayment` lists
119
+ however suits your setup, and call `reconcile_statement()`. Options:
120
+
121
+ - Parse a PDF or CSV statement export yourself (see
122
+ `examples/reconcile_csv.py`)
123
+ - Pull the ledger side from Sage 50 with `recskit.sage_adapter` (see
124
+ `examples/reconcile_sage.py`)
125
+ - Write your own adapter for Xero, QuickBooks, or anything else --
126
+ `sage_adapter.py` is about 100 lines and a reasonable template to
127
+ copy
128
+
129
+ ## Testing
130
+
131
+ ```bash
132
+ pip install pytest
133
+ pytest
134
+ ```
135
+
136
+ The core test suite has no dependencies to mock -- it's all plain
137
+ Python objects in, plain Python objects out.
138
+
139
+ ## Troubleshooting
140
+
141
+ | Symptom | Likely cause | Fix |
142
+ |---|---|---|
143
+ | A statement line matches the wrong ledger invoice | Shouldn't happen -- recskit always tries an exact ref match before substring | If you see this, please open an issue with the two refs involved |
144
+ | Balance agrees but you still see mismatches/missing items | This is expected and correct -- see "What problem this solves" above | Investigate each flagged line; a tied total doesn't guarantee every line is right |
145
+ | Payment shows as missing despite being in the ledger | Amount differs by more than the tolerance, and the reference also doesn't appear as a substring | Widen `tolerance` in `match_payment()`, or check the payment was recorded correctly |
146
+ | `ImportError` on `sagekit` | The `sage` extra wasn't installed | `pip install -e ".[sage]"`, or install sagekit directly |
147
+
148
+ ## License
149
+
150
+ MIT -- see [LICENSE](LICENSE). Do whatever you like with this; a
151
+ credit or a [Ko-fi tip](https://ko-fi.com/duckboard) is appreciated
152
+ but never required.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "recskit"
7
+ version = "0.1.0"
8
+ description = "Reconcile a supplier or customer statement against your accounting ledger: match invoices, credit notes, and payments, flag mismatches and missing items, compare balances. No dependencies in the core -- works with any data source."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Duckboard" }
14
+ ]
15
+ dependencies = []
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Office/Business :: Financial :: Accounting",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ sage = [
26
+ "sagekit>=0.1.0",
27
+ ]
28
+ parse-pdf = [
29
+ "pdfplumber>=0.10",
30
+ "anthropic>=0.30",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/Duckboard/recskit"
35
+ "Buy me a coffee" = "https://ko-fi.com/duckboard"
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["recskit*"]
@@ -0,0 +1,45 @@
1
+ """
2
+ recskit -- reconcile a supplier or customer statement against your own
3
+ accounting ledger: match invoices, credit notes, and payments, flag
4
+ mismatches and missing items, and compare balances.
5
+
6
+ The core logic has no dependencies at all and doesn't care where your
7
+ data came from -- CSV, a PDF you parsed by hand, Sage, Xero,
8
+ QuickBooks. recskit.sage_adapter is one optional, concrete way to pull
9
+ the ledger side out of Sage 50 (via sagekit); write your own equivalent
10
+ for anything else.
11
+
12
+ from recskit import StatementItem, LedgerInvoice, LedgerPayment
13
+ from recskit import reconcile_statement, calc_due_date
14
+
15
+ See README.md for the full walkthrough.
16
+ """
17
+
18
+ from .due_dates import DUE_FROM_MODES, calc_due_date
19
+ from .matching import find_best_name_match, match_invoice, match_payment, normalize_name
20
+ from .models import (
21
+ LedgerInvoice,
22
+ LedgerPayment,
23
+ MatchResult,
24
+ ReconciliationResult,
25
+ StatementItem,
26
+ )
27
+ from .reconcile import reconcile_statement
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "StatementItem",
33
+ "LedgerInvoice",
34
+ "LedgerPayment",
35
+ "MatchResult",
36
+ "ReconciliationResult",
37
+ "reconcile_statement",
38
+ "match_invoice",
39
+ "match_payment",
40
+ "normalize_name",
41
+ "find_best_name_match",
42
+ "calc_due_date",
43
+ "DUE_FROM_MODES",
44
+ "__version__",
45
+ ]
@@ -0,0 +1,77 @@
1
+ """
2
+ recskit.due_dates
3
+ ==================
4
+ Calculates an invoice's due date from its invoice date and a supplier's
5
+ (or customer's) payment terms. Different trading partners quote terms
6
+ in different ways -- "30 days", "29 days from the end of the month
7
+ following", "last day of the month after invoice month" -- and getting
8
+ this wrong is exactly what makes an "overdue" flag either useless
9
+ (triggers too early) or dangerous (triggers too late).
10
+ """
11
+
12
+ from datetime import date, timedelta
13
+
14
+ # The four due-date conventions this covers. If a trading partner uses
15
+ # something not listed here, "invoice_date" (a flat N days from the
16
+ # invoice date) is the safest fallback -- it's the most common by far.
17
+ DUE_FROM_MODES = (
18
+ "invoice_date",
19
+ "start_of_following_month",
20
+ "end_of_month",
21
+ "end_of_following_month",
22
+ )
23
+
24
+
25
+ def _last_day_of_month(year: int, month: int) -> date:
26
+ if month == 12:
27
+ return date(year, 12, 31)
28
+ return date(year, month + 1, 1) - timedelta(days=1)
29
+
30
+
31
+ def calc_due_date(invoice_date: date, terms_days: int, due_from: str = "invoice_date") -> date:
32
+ """
33
+ Calculate an invoice's due date.
34
+
35
+ due_from options:
36
+ "invoice_date" -- N days from the invoice date
37
+ (the default, and most common)
38
+ "start_of_following_month" -- N days from the 1st of the month
39
+ after the invoice date, e.g.
40
+ "29 days" meaning the 30th of
41
+ the month after next
42
+ "end_of_month" -- N days from the last day of the
43
+ invoice's own month, e.g.
44
+ "14 days EOM"
45
+ "end_of_following_month" -- the last day of the month AFTER
46
+ the invoice month, no day offset
47
+ (terms_days is ignored for this
48
+ mode -- it's a fixed rule, e.g.
49
+ "last day of the month following
50
+ the invoice")
51
+
52
+ Raises ValueError for an unrecognised due_from value, rather than
53
+ silently falling back to a default -- an unnoticed typo here would
54
+ quietly miscalculate every due date for that trading partner.
55
+ """
56
+ if due_from not in DUE_FROM_MODES:
57
+ raise ValueError(f"Unknown due_from {due_from!r} -- must be one of {DUE_FROM_MODES}")
58
+
59
+ if due_from == "start_of_following_month":
60
+ if invoice_date.month == 12:
61
+ first_next = date(invoice_date.year + 1, 1, 1)
62
+ else:
63
+ first_next = date(invoice_date.year, invoice_date.month + 1, 1)
64
+ return first_next + timedelta(days=terms_days)
65
+
66
+ if due_from == "end_of_month":
67
+ return _last_day_of_month(invoice_date.year, invoice_date.month) + timedelta(days=terms_days)
68
+
69
+ if due_from == "end_of_following_month":
70
+ if invoice_date.month == 12:
71
+ target_year, target_month = invoice_date.year + 1, 1
72
+ else:
73
+ target_year, target_month = invoice_date.year, invoice_date.month + 1
74
+ return _last_day_of_month(target_year, target_month)
75
+
76
+ # "invoice_date"
77
+ return invoice_date + timedelta(days=terms_days)
@@ -0,0 +1,116 @@
1
+ """
2
+ recskit.matching
3
+ =================
4
+ Matches individual statement lines against ledger transactions.
5
+
6
+ The core lesson baked into this module: **always try an exact
7
+ reference match before falling back to a substring match.** A short
8
+ reference like "9910" can accidentally turn up as a *substring* of an
9
+ unrelated, older invoice's reference on a long-standing account. If you
10
+ match on substring first, you silently pair the statement line with
11
+ the wrong ledger invoice -- and report a confident-looking "mismatch"
12
+ amount instead of correctly reporting no match at all. This bit a real
13
+ reconciliation run before the exact-match-first rule was added, so
14
+ it's not a hypothetical -- keep the ordering.
15
+ """
16
+
17
+ import re
18
+ from typing import List, Optional, Tuple
19
+
20
+ from .models import LedgerInvoice, LedgerPayment, MatchResult, StatementItem
21
+
22
+
23
+ def normalize_name(name: str) -> str:
24
+ """
25
+ Strip everything except letters and digits and uppercase the rest.
26
+ Account/company names are rarely entered consistently ("N.D. John"
27
+ vs "ND John", "St." vs "St", extra spaces) -- comparing on
28
+ letters/digits only avoids false negatives from punctuation alone.
29
+ """
30
+ return re.sub(r"[^A-Z0-9]", "", (name or "").upper())
31
+
32
+
33
+ def find_best_name_match(
34
+ search_term: str, candidates: List[Tuple[str, str]], tie_breaker_counts: Optional[dict] = None
35
+ ) -> Optional[Tuple[str, str]]:
36
+ """
37
+ Find the best match for `search_term` among `candidates`, a list of
38
+ (id, name) tuples, using normalize_name() for a punctuation-
39
+ insensitive comparison.
40
+
41
+ Returns the matching (id, name) tuple, or None if nothing matches.
42
+ If more than one candidate matches, `tie_breaker_counts` (a dict of
43
+ id -> some comparable score, e.g. transaction count) picks the
44
+ highest-scoring one; without it, the first match wins.
45
+ """
46
+ norm_search = normalize_name(search_term)
47
+ matches = [c for c in candidates if norm_search in normalize_name(c[1])]
48
+
49
+ if not matches:
50
+ return None
51
+ if len(matches) == 1 or not tie_breaker_counts:
52
+ return matches[0]
53
+
54
+ return max(matches, key=lambda c: tie_breaker_counts.get(c[0], 0))
55
+
56
+
57
+ def match_invoice(
58
+ item: StatementItem, ledger_invoices: List[LedgerInvoice], tolerance: float = 0.01
59
+ ) -> MatchResult:
60
+ """
61
+ Match one invoice/credit-note statement line against the ledger.
62
+
63
+ Tries an exact match on `ref` (and `alt_ref`, if the statement
64
+ supplied a second reference for this line) first. Only if nothing
65
+ matches exactly does it fall back to a substring match -- see the
66
+ module docstring for why the ordering matters.
67
+ """
68
+ refs_to_try = [item.ref.upper()]
69
+ if item.alt_ref:
70
+ refs_to_try.append(item.alt_ref.upper())
71
+
72
+ exact = [inv for inv in ledger_invoices if inv.ref.upper() in refs_to_try]
73
+ candidates = exact or [
74
+ inv for inv in ledger_invoices if any(r in inv.ref.upper() for r in refs_to_try)
75
+ ]
76
+
77
+ if not candidates:
78
+ return MatchResult(item=item, status="missing", note=f"No ledger entry for ref {item.ref!r}")
79
+
80
+ ledger_invoice = candidates[0]
81
+ if abs(ledger_invoice.amount - item.amount) < tolerance:
82
+ return MatchResult(
83
+ item=item, status="ok", ledger_ref=ledger_invoice.ref, ledger_amount=ledger_invoice.amount
84
+ )
85
+
86
+ return MatchResult(
87
+ item=item,
88
+ status="mismatch",
89
+ ledger_ref=ledger_invoice.ref,
90
+ ledger_amount=ledger_invoice.amount,
91
+ note=f"Statement £{item.amount:,.2f} vs ledger £{ledger_invoice.amount:,.2f}",
92
+ )
93
+
94
+
95
+ def match_payment(
96
+ item: StatementItem, ledger_payments: List[LedgerPayment], tolerance: float = 0.01
97
+ ) -> MatchResult:
98
+ """
99
+ Match one payment statement line against the ledger.
100
+
101
+ Payments are matched by **amount first**, then by reference text --
102
+ the opposite order to invoices. This is deliberate: payment
103
+ references (BACS refs, bank descriptions) very often differ
104
+ between what a supplier's statement shows and what actually lands
105
+ in your ledger, whereas the amount is reliable.
106
+ """
107
+ by_amount = [p for p in ledger_payments if abs(p.amount - item.amount) < tolerance]
108
+ candidates = by_amount or [
109
+ p for p in ledger_payments if item.ref and item.ref.upper() in (p.ref or "").upper()
110
+ ]
111
+
112
+ if not candidates:
113
+ return MatchResult(item=item, status="missing", note=f"No ledger payment matching £{item.amount:,.2f}")
114
+
115
+ payment = candidates[0]
116
+ return MatchResult(item=item, status="ok", ledger_ref=payment.ref, ledger_amount=payment.amount)