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 +21 -0
- recskit-0.1.0/PKG-INFO +175 -0
- recskit-0.1.0/README.md +152 -0
- recskit-0.1.0/pyproject.toml +38 -0
- recskit-0.1.0/recskit/__init__.py +45 -0
- recskit-0.1.0/recskit/due_dates.py +77 -0
- recskit-0.1.0/recskit/matching.py +116 -0
- recskit-0.1.0/recskit/models.py +96 -0
- recskit-0.1.0/recskit/pdf_statement.py +194 -0
- recskit-0.1.0/recskit/reconcile.py +73 -0
- recskit-0.1.0/recskit/sage_adapter.py +125 -0
- recskit-0.1.0/recskit.egg-info/PKG-INFO +175 -0
- recskit-0.1.0/recskit.egg-info/SOURCES.txt +20 -0
- recskit-0.1.0/recskit.egg-info/dependency_links.txt +1 -0
- recskit-0.1.0/recskit.egg-info/requires.txt +7 -0
- recskit-0.1.0/recskit.egg-info/top_level.txt +1 -0
- recskit-0.1.0/setup.cfg +4 -0
- recskit-0.1.0/tests/test_due_dates.py +37 -0
- recskit-0.1.0/tests/test_matching.py +108 -0
- recskit-0.1.0/tests/test_pdf_statement.py +65 -0
- recskit-0.1.0/tests/test_reconcile.py +90 -0
- recskit-0.1.0/tests/test_sage_adapter.py +72 -0
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.
|
recskit-0.1.0/README.md
ADDED
|
@@ -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)
|