recskit 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.
- recskit/__init__.py +45 -0
- recskit/due_dates.py +77 -0
- recskit/matching.py +116 -0
- recskit/models.py +96 -0
- recskit/pdf_statement.py +194 -0
- recskit/reconcile.py +73 -0
- recskit/sage_adapter.py +125 -0
- recskit-0.1.0.dist-info/METADATA +175 -0
- recskit-0.1.0.dist-info/RECORD +12 -0
- recskit-0.1.0.dist-info/WHEEL +5 -0
- recskit-0.1.0.dist-info/licenses/LICENSE +21 -0
- recskit-0.1.0.dist-info/top_level.txt +1 -0
recskit/__init__.py
ADDED
|
@@ -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
|
+
]
|
recskit/due_dates.py
ADDED
|
@@ -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)
|
recskit/matching.py
ADDED
|
@@ -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)
|
recskit/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
recskit.models
|
|
3
|
+
==============
|
|
4
|
+
Plain data containers used throughout recskit. Nothing in this module
|
|
5
|
+
talks to any external system -- these are just the shapes that
|
|
6
|
+
statement lines and ledger transactions get put into before matching.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import date as date_type
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
VALID_ITEM_TYPES = ("invoice", "credit_note", "payment")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class StatementItem:
|
|
18
|
+
"""
|
|
19
|
+
One line from a supplier or customer statement.
|
|
20
|
+
|
|
21
|
+
`amount` is always positive, regardless of how the statement itself
|
|
22
|
+
signs it (some show purchases as negative, some don't -- normalise
|
|
23
|
+
to positive before creating this).
|
|
24
|
+
|
|
25
|
+
`alt_ref` covers statements that print two references for the same
|
|
26
|
+
line (e.g. an internal "Reference" plus their own "Invoice No.") --
|
|
27
|
+
your ledger might have it booked under either one.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
type: str
|
|
31
|
+
ref: str
|
|
32
|
+
amount: float
|
|
33
|
+
date: Optional[date_type] = None
|
|
34
|
+
alt_ref: str = ""
|
|
35
|
+
due_date: Optional[date_type] = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self):
|
|
38
|
+
if self.type not in VALID_ITEM_TYPES:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"StatementItem.type must be one of {VALID_ITEM_TYPES}, got {self.type!r}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class LedgerInvoice:
|
|
46
|
+
"""An invoice or credit note as it exists in your accounting ledger."""
|
|
47
|
+
|
|
48
|
+
ref: str
|
|
49
|
+
amount: float # gross amount, positive
|
|
50
|
+
date: Optional[date_type] = None
|
|
51
|
+
outstanding: float = 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class LedgerPayment:
|
|
56
|
+
"""A payment as it exists in your accounting ledger."""
|
|
57
|
+
|
|
58
|
+
ref: str
|
|
59
|
+
amount: float # positive
|
|
60
|
+
date: Optional[date_type] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class MatchResult:
|
|
65
|
+
"""The outcome of trying to match one statement item against the ledger."""
|
|
66
|
+
|
|
67
|
+
item: StatementItem
|
|
68
|
+
status: str # "ok", "mismatch", or "missing"
|
|
69
|
+
ledger_ref: Optional[str] = None
|
|
70
|
+
ledger_amount: Optional[float] = None
|
|
71
|
+
note: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ReconciliationResult:
|
|
76
|
+
"""
|
|
77
|
+
Everything you need to report on a single statement's reconciliation
|
|
78
|
+
against your ledger.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
invoice_results: List[MatchResult] = field(default_factory=list)
|
|
82
|
+
payment_results: List[MatchResult] = field(default_factory=list)
|
|
83
|
+
extra_in_ledger: List[LedgerInvoice] = field(default_factory=list)
|
|
84
|
+
statement_balance: float = 0.0
|
|
85
|
+
ledger_balance: float = 0.0
|
|
86
|
+
balance_difference: float = 0.0
|
|
87
|
+
issue_count: int = 0
|
|
88
|
+
agreed: bool = True
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def missing_items(self) -> List[MatchResult]:
|
|
92
|
+
return [r for r in self.invoice_results + self.payment_results if r.status == "missing"]
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def mismatched_items(self) -> List[MatchResult]:
|
|
96
|
+
return [r for r in self.invoice_results + self.payment_results if r.status == "mismatch"]
|
recskit/pdf_statement.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
recskit.pdf_statement
|
|
3
|
+
======================
|
|
4
|
+
Optional module: turns a statement PDF into a list of StatementItem
|
|
5
|
+
objects, using pdfplumber to pull out the text and the Claude API to
|
|
6
|
+
turn that text into structured data.
|
|
7
|
+
|
|
8
|
+
This is the part of a real reconciliation workflow that's actually
|
|
9
|
+
hard -- matching and arithmetic (the rest of recskit) is comparatively
|
|
10
|
+
easy once you have clean structured data. Every supplier or customer
|
|
11
|
+
formats their statement differently, and hand-writing a parser per
|
|
12
|
+
format doesn't scale. Handing the raw extracted text to an LLM with a
|
|
13
|
+
strict output shape does, and adapts to a new statement layout with no
|
|
14
|
+
code changes.
|
|
15
|
+
|
|
16
|
+
Requires the optional "parse-pdf" extra:
|
|
17
|
+
|
|
18
|
+
pip install recskit[parse-pdf]
|
|
19
|
+
|
|
20
|
+
and an Anthropic API key (ANTHROPIC_API_KEY environment variable, or
|
|
21
|
+
passed explicitly) -- extract_pdf_text() has no such requirement and
|
|
22
|
+
works standalone if you'd rather wire up a different parser.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from typing import List, Optional
|
|
28
|
+
|
|
29
|
+
from .models import StatementItem
|
|
30
|
+
|
|
31
|
+
PARSING_SYSTEM_PROMPT = """You are extracting structured data from a supplier or customer \
|
|
32
|
+
account statement PDF, for automated reconciliation against an accounting ledger.
|
|
33
|
+
|
|
34
|
+
Return ONLY valid JSON (no markdown code fences, no commentary) matching exactly this shape:
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
"statement_date": "DD/MM/YYYY",
|
|
38
|
+
"statement_balance": <number, the total balance due printed at the bottom of the statement>,
|
|
39
|
+
"items": [
|
|
40
|
+
{
|
|
41
|
+
"type": "invoice" | "credit_note" | "payment",
|
|
42
|
+
"ref": "the reference shown on the statement for this line",
|
|
43
|
+
"alt_ref": "a second reference for the same line, if the statement prints two (optional)",
|
|
44
|
+
"amount": <number, always positive regardless of how it is signed on the statement>,
|
|
45
|
+
"date": "DD/MM/YYYY",
|
|
46
|
+
"due_date": "DD/MM/YYYY (optional -- only include if a due date is explicitly printed next to this line)"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Rules:
|
|
52
|
+
- Do NOT include a brought-forward / balance-forward line as an item -- it is a summary of
|
|
53
|
+
older transactions, not a document in its own right.
|
|
54
|
+
- A payment shown with no document reference (BACS, a bank transfer, etc.) should get a
|
|
55
|
+
short generic "ref" like "bacs" rather than being left blank.
|
|
56
|
+
- Credit notes use "type": "credit_note".
|
|
57
|
+
- If unsure whether a line is an invoice or credit note, use the sign or column it's shown
|
|
58
|
+
in as a guide.
|
|
59
|
+
- Keep dates in whichever format the statement itself uses, consistently, as DD/MM/YYYY
|
|
60
|
+
unless the statement is clearly in another locale's date format.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extract_pdf_text(pdf_path_or_bytes) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Extract all text from a statement PDF, page by page, joined with
|
|
67
|
+
newlines. Accepts a file path (str/Path) or raw PDF bytes.
|
|
68
|
+
|
|
69
|
+
This has no dependency on the Claude API -- useful on its own if
|
|
70
|
+
you'd rather feed the text to a different parser (a regex-based
|
|
71
|
+
one for a single known format, another LLM, etc).
|
|
72
|
+
"""
|
|
73
|
+
import io
|
|
74
|
+
|
|
75
|
+
import pdfplumber
|
|
76
|
+
|
|
77
|
+
source = io.BytesIO(pdf_path_or_bytes) if isinstance(pdf_path_or_bytes, bytes) else pdf_path_or_bytes
|
|
78
|
+
|
|
79
|
+
parts = []
|
|
80
|
+
with pdfplumber.open(source) as pdf:
|
|
81
|
+
for page in pdf.pages:
|
|
82
|
+
parts.append(page.extract_text() or "")
|
|
83
|
+
return "\n".join(parts)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_statement_text(
|
|
87
|
+
statement_text: str,
|
|
88
|
+
counterparty_name: str = "",
|
|
89
|
+
parsing_notes: str = "",
|
|
90
|
+
api_key: Optional[str] = None,
|
|
91
|
+
model: str = "claude-sonnet-5",
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Send extracted statement text to the Claude API and get back the
|
|
95
|
+
raw parsed dict (statement_date, statement_balance, items) --
|
|
96
|
+
matching PARSING_SYSTEM_PROMPT's shape exactly.
|
|
97
|
+
|
|
98
|
+
`counterparty_name` (the supplier or customer's name) and
|
|
99
|
+
`parsing_notes` (any known quirks of their statement format, e.g.
|
|
100
|
+
"this supplier prints two references per line -- put the second
|
|
101
|
+
one in alt_ref") are optional context that measurably improves
|
|
102
|
+
parsing accuracy for tricky or unusual layouts; both are free text.
|
|
103
|
+
|
|
104
|
+
`api_key` falls back to the ANTHROPIC_API_KEY environment variable
|
|
105
|
+
if not passed explicitly -- this function does not read a .env
|
|
106
|
+
file itself.
|
|
107
|
+
|
|
108
|
+
Raises whatever the anthropic client raises on an API error, and
|
|
109
|
+
json.JSONDecodeError if the model's response isn't valid JSON
|
|
110
|
+
despite the prompt (rare, but don't assume it's impossible).
|
|
111
|
+
"""
|
|
112
|
+
from anthropic import Anthropic
|
|
113
|
+
|
|
114
|
+
client = Anthropic(api_key=api_key) if api_key else Anthropic()
|
|
115
|
+
|
|
116
|
+
user_prompt = ""
|
|
117
|
+
if counterparty_name:
|
|
118
|
+
user_prompt += f"Statement is from: {counterparty_name}\n"
|
|
119
|
+
if parsing_notes:
|
|
120
|
+
user_prompt += f"Known quirks for this statement format: {parsing_notes}\n"
|
|
121
|
+
user_prompt += f"\nStatement text:\n\n{statement_text}"
|
|
122
|
+
|
|
123
|
+
response = client.messages.create(
|
|
124
|
+
model=model,
|
|
125
|
+
max_tokens=4000,
|
|
126
|
+
system=PARSING_SYSTEM_PROMPT,
|
|
127
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
raw = response.content[0].text.strip()
|
|
131
|
+
if raw.startswith("```"):
|
|
132
|
+
raw = raw.strip("`")
|
|
133
|
+
if raw.lower().startswith("json"):
|
|
134
|
+
raw = raw[4:]
|
|
135
|
+
return json.loads(raw.strip())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def statement_items_from_parsed(parsed: dict) -> List[StatementItem]:
|
|
139
|
+
"""
|
|
140
|
+
Convert the raw dict from parse_statement_text() (or your own
|
|
141
|
+
parser, provided it matches the same shape) into a list of
|
|
142
|
+
StatementItem objects, ready for reconcile_statement().
|
|
143
|
+
|
|
144
|
+
Does not touch statement_date / statement_balance -- read those
|
|
145
|
+
directly off `parsed` yourself; reconcile_statement() takes the
|
|
146
|
+
balance as a separate argument rather than as part of this list.
|
|
147
|
+
"""
|
|
148
|
+
items = []
|
|
149
|
+
for raw_item in parsed.get("items", []):
|
|
150
|
+
items.append(
|
|
151
|
+
StatementItem(
|
|
152
|
+
type=raw_item["type"],
|
|
153
|
+
ref=raw_item["ref"],
|
|
154
|
+
amount=float(raw_item["amount"]),
|
|
155
|
+
date=_parse_date(raw_item.get("date")),
|
|
156
|
+
alt_ref=raw_item.get("alt_ref", "") or "",
|
|
157
|
+
due_date=_parse_date(raw_item.get("due_date")),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return items
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def parse_pdf_to_statement(
|
|
164
|
+
pdf_path_or_bytes,
|
|
165
|
+
counterparty_name: str = "",
|
|
166
|
+
parsing_notes: str = "",
|
|
167
|
+
api_key: Optional[str] = None,
|
|
168
|
+
model: str = "claude-sonnet-5",
|
|
169
|
+
) -> tuple:
|
|
170
|
+
"""
|
|
171
|
+
End-to-end convenience: PDF in, (statement_date, statement_balance,
|
|
172
|
+
items) out, ready to hand straight to reconcile_statement().
|
|
173
|
+
|
|
174
|
+
from recskit.pdf_statement import parse_pdf_to_statement
|
|
175
|
+
from recskit import reconcile_statement
|
|
176
|
+
|
|
177
|
+
stmt_date, stmt_balance, items = parse_pdf_to_statement("statement.pdf", "Acme Supplies")
|
|
178
|
+
result = reconcile_statement(items, ledger_invoices, ledger_payments, stmt_balance)
|
|
179
|
+
"""
|
|
180
|
+
text = extract_pdf_text(pdf_path_or_bytes)
|
|
181
|
+
parsed = parse_statement_text(text, counterparty_name, parsing_notes, api_key, model)
|
|
182
|
+
items = statement_items_from_parsed(parsed)
|
|
183
|
+
return _parse_date(parsed.get("statement_date")), float(parsed.get("statement_balance", 0)), items
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_date(value):
|
|
187
|
+
if not value:
|
|
188
|
+
return None
|
|
189
|
+
for fmt in ("%d/%m/%Y", "%Y-%m-%d"):
|
|
190
|
+
try:
|
|
191
|
+
return datetime.strptime(value, fmt).date()
|
|
192
|
+
except ValueError:
|
|
193
|
+
continue
|
|
194
|
+
return None
|
recskit/reconcile.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
recskit.reconcile
|
|
3
|
+
==================
|
|
4
|
+
Ties matching.py and models.py together into one call: hand over a
|
|
5
|
+
statement's line items plus your ledger's invoices and payments for
|
|
6
|
+
that account, get back a ReconciliationResult with every line matched,
|
|
7
|
+
mismatched, or flagged missing, plus a balance comparison.
|
|
8
|
+
|
|
9
|
+
This module doesn't care where the statement or ledger data came from
|
|
10
|
+
-- CSV, a PDF you parsed by hand, Sage, Xero, QuickBooks, whatever.
|
|
11
|
+
See recskit.sage_adapter for one concrete way to get ledger data out of
|
|
12
|
+
Sage 50, or write your own equivalent for another system.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from .matching import match_invoice, match_payment
|
|
18
|
+
from .models import LedgerInvoice, LedgerPayment, ReconciliationResult, StatementItem
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reconcile_statement(
|
|
22
|
+
items: List[StatementItem],
|
|
23
|
+
ledger_invoices: List[LedgerInvoice],
|
|
24
|
+
ledger_payments: List[LedgerPayment],
|
|
25
|
+
statement_balance: float,
|
|
26
|
+
ledger_balance: Optional[float] = None,
|
|
27
|
+
balance_tolerance: float = 0.02,
|
|
28
|
+
) -> ReconciliationResult:
|
|
29
|
+
"""
|
|
30
|
+
Reconcile one statement against your ledger.
|
|
31
|
+
|
|
32
|
+
`ledger_balance` defaults to the sum of `outstanding` across
|
|
33
|
+
`ledger_invoices` if not supplied directly -- pass it explicitly if
|
|
34
|
+
your ledger's outstanding figure needs to come from somewhere else.
|
|
35
|
+
|
|
36
|
+
An item only counts as a discrepancy (invoice/credit-note or
|
|
37
|
+
payment) if it's "mismatch" or "missing" -- an exact match is never
|
|
38
|
+
counted, no matter which fallback path found it.
|
|
39
|
+
"""
|
|
40
|
+
doc_items = [i for i in items if i.type in ("invoice", "credit_note")]
|
|
41
|
+
payment_items = [i for i in items if i.type == "payment"]
|
|
42
|
+
|
|
43
|
+
invoice_results = [match_invoice(item, ledger_invoices) for item in doc_items]
|
|
44
|
+
payment_results = [match_payment(item, ledger_payments) for item in payment_items]
|
|
45
|
+
|
|
46
|
+
if ledger_balance is None:
|
|
47
|
+
ledger_balance = sum(inv.outstanding for inv in ledger_invoices)
|
|
48
|
+
|
|
49
|
+
# Ledger invoices that don't appear (even as a substring match) on
|
|
50
|
+
# any statement line -- usually older items sitting in a brought-
|
|
51
|
+
# forward balance, but worth surfacing rather than silently ignoring.
|
|
52
|
+
stmt_refs = {i.ref.upper() for i in doc_items}
|
|
53
|
+
extra_in_ledger = [
|
|
54
|
+
inv for inv in ledger_invoices if not any(r in inv.ref.upper() for r in stmt_refs)
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
balance_difference = round(ledger_balance - statement_balance, 2)
|
|
58
|
+
|
|
59
|
+
issue_count = sum(1 for r in invoice_results + payment_results if r.status != "ok")
|
|
60
|
+
balance_ok = abs(balance_difference) < balance_tolerance
|
|
61
|
+
if not balance_ok:
|
|
62
|
+
issue_count += 1
|
|
63
|
+
|
|
64
|
+
return ReconciliationResult(
|
|
65
|
+
invoice_results=invoice_results,
|
|
66
|
+
payment_results=payment_results,
|
|
67
|
+
extra_in_ledger=extra_in_ledger,
|
|
68
|
+
statement_balance=statement_balance,
|
|
69
|
+
ledger_balance=ledger_balance,
|
|
70
|
+
balance_difference=balance_difference,
|
|
71
|
+
issue_count=issue_count,
|
|
72
|
+
agreed=(issue_count == 0),
|
|
73
|
+
)
|
recskit/sage_adapter.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
recskit.sage_adapter
|
|
3
|
+
=====================
|
|
4
|
+
Optional adapter that pulls a purchase-ledger account's invoices,
|
|
5
|
+
credit notes, and payments out of Sage 50 (via sagekit) and converts
|
|
6
|
+
them into recskit's LedgerInvoice / LedgerPayment shapes, ready for
|
|
7
|
+
reconcile_statement().
|
|
8
|
+
|
|
9
|
+
This is one concrete data source -- recskit's reconciliation logic
|
|
10
|
+
itself doesn't know or care where the ledger side came from. If you're
|
|
11
|
+
on Xero, QuickBooks, or anything else, write your own equivalent that
|
|
12
|
+
returns the same two lists; everything downstream is unchanged.
|
|
13
|
+
|
|
14
|
+
Requires the optional "sage" extra:
|
|
15
|
+
|
|
16
|
+
pip install recskit[sage]
|
|
17
|
+
|
|
18
|
+
or install sagekit directly -- see its own README for the Sage 50
|
|
19
|
+
ODBC connection details (DSN, credentials, column auto-detection).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import List, Tuple
|
|
24
|
+
|
|
25
|
+
from .models import LedgerInvoice, LedgerPayment
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize_name(name: str) -> str:
|
|
29
|
+
return re.sub(r"[^A-Z0-9]", "", (name or "").upper())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_purchase_ledger_account(cursor, search_term: str) -> Tuple[str, str]:
|
|
33
|
+
"""
|
|
34
|
+
Find a supplier account in Sage's PURCHASE_LEDGER by a fuzzy name
|
|
35
|
+
match (ignoring punctuation/spacing, since account names aren't
|
|
36
|
+
entered consistently). If more than one account matches, picks the
|
|
37
|
+
one with the most PI/PC/PP transactions.
|
|
38
|
+
|
|
39
|
+
Returns (account_ref, account_name). Raises LookupError if nothing
|
|
40
|
+
matches.
|
|
41
|
+
"""
|
|
42
|
+
cursor.execute("SELECT ACCOUNT_REF, NAME FROM PURCHASE_LEDGER")
|
|
43
|
+
all_accounts = cursor.fetchall()
|
|
44
|
+
|
|
45
|
+
norm_search = _normalize_name(search_term)
|
|
46
|
+
matches = [a for a in all_accounts if norm_search in _normalize_name(a[1])]
|
|
47
|
+
|
|
48
|
+
if not matches:
|
|
49
|
+
raise LookupError(f"No PURCHASE_LEDGER account matching {search_term!r}")
|
|
50
|
+
|
|
51
|
+
if len(matches) == 1:
|
|
52
|
+
return matches[0][0], matches[0][1]
|
|
53
|
+
|
|
54
|
+
best, best_count = matches[0], -1
|
|
55
|
+
for account_ref, name in matches:
|
|
56
|
+
safe = account_ref.replace("'", "''")
|
|
57
|
+
cursor.execute(
|
|
58
|
+
f"""
|
|
59
|
+
SELECT COUNT(*) FROM AUDIT_HEADER
|
|
60
|
+
WHERE ACCOUNT_REF = '{safe}'
|
|
61
|
+
AND TYPE IN ('PI', 'PC', 'PP')
|
|
62
|
+
AND DELETED_FLAG = 0
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
count = cursor.fetchone()[0]
|
|
66
|
+
if count > best_count:
|
|
67
|
+
best_count = count
|
|
68
|
+
best = (account_ref, name)
|
|
69
|
+
|
|
70
|
+
return best[0], best[1]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pull_purchase_ledger(
|
|
74
|
+
cursor, account_ref: str
|
|
75
|
+
) -> Tuple[List[LedgerInvoice], List[LedgerPayment]]:
|
|
76
|
+
"""
|
|
77
|
+
Pull outstanding invoices/credit notes (type PI/PC) and payments
|
|
78
|
+
(type PP) for a Sage purchase ledger account, converted to
|
|
79
|
+
recskit's LedgerInvoice / LedgerPayment dataclasses.
|
|
80
|
+
|
|
81
|
+
Amounts come back from Sage's ODBC layer as negative for purchase
|
|
82
|
+
transactions -- this converts them to positive, matching the
|
|
83
|
+
convention recskit uses everywhere.
|
|
84
|
+
"""
|
|
85
|
+
safe_ref = account_ref.replace("'", "''")
|
|
86
|
+
|
|
87
|
+
cursor.execute(
|
|
88
|
+
f"""
|
|
89
|
+
SELECT INV_REF, DATE, OUTSTANDING, GROSS_AMOUNT
|
|
90
|
+
FROM AUDIT_HEADER
|
|
91
|
+
WHERE ACCOUNT_REF = '{safe_ref}'
|
|
92
|
+
AND TYPE IN ('PI', 'PC')
|
|
93
|
+
AND DELETED_FLAG = 0
|
|
94
|
+
AND OUTSTANDING <> 0
|
|
95
|
+
ORDER BY DATE
|
|
96
|
+
"""
|
|
97
|
+
)
|
|
98
|
+
invoice_rows = cursor.fetchall()
|
|
99
|
+
|
|
100
|
+
cursor.execute(
|
|
101
|
+
f"""
|
|
102
|
+
SELECT INV_REF, DATE, GROSS_AMOUNT
|
|
103
|
+
FROM AUDIT_HEADER
|
|
104
|
+
WHERE ACCOUNT_REF = '{safe_ref}'
|
|
105
|
+
AND TYPE = 'PP'
|
|
106
|
+
AND DELETED_FLAG = 0
|
|
107
|
+
ORDER BY DATE DESC
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
110
|
+
payment_rows = cursor.fetchall()
|
|
111
|
+
|
|
112
|
+
invoices = [
|
|
113
|
+
LedgerInvoice(
|
|
114
|
+
ref=str(r[0]),
|
|
115
|
+
amount=abs(float(r[3] or 0)),
|
|
116
|
+
date=r[1],
|
|
117
|
+
outstanding=float(r[2] or 0),
|
|
118
|
+
)
|
|
119
|
+
for r in invoice_rows
|
|
120
|
+
]
|
|
121
|
+
payments = [
|
|
122
|
+
LedgerPayment(ref=str(r[0]), amount=abs(float(r[2] or 0)), date=r[1]) for r in payment_rows
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
return invoices, payments
|
|
@@ -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,12 @@
|
|
|
1
|
+
recskit/__init__.py,sha256=9AHjirPc4w9P3Is8ZnE9mk43GxEKs4OG5XnmhU5uswE,1346
|
|
2
|
+
recskit/due_dates.py,sha256=Di7ZZBzoXEBnOEq2vndusVpnHEcC1GPn2u_NqQFgUYM,3386
|
|
3
|
+
recskit/matching.py,sha256=325qNVOH4AU14noegYH16JLgcvgqB0qYleUSpvrMXKQ,4664
|
|
4
|
+
recskit/models.py,sha256=IQhRmEnA5J2cyQ3GJEPZy-o3TZu2b-A6wEHmeoCcmo8,2890
|
|
5
|
+
recskit/pdf_statement.py,sha256=Czu7jBy6H4el-qf1sVuBtg0B3poaywEBliWM6tkfmzs,7245
|
|
6
|
+
recskit/reconcile.py,sha256=88e-nEGmXKnJ_M4xWeV1cx6T_SBZHXggfmj73bcG9E8,3004
|
|
7
|
+
recskit/sage_adapter.py,sha256=UV0BO-2fXPGFZbEAcHGbDuPAy1-VGl2Pxg0iotNnQ3c,3962
|
|
8
|
+
recskit-0.1.0.dist-info/licenses/LICENSE,sha256=cCyz-t_gcRxOT076nbb_MjadJUXfbQXEi_aSqvemsVw,1087
|
|
9
|
+
recskit-0.1.0.dist-info/METADATA,sha256=M42puf7V3MUGA7IGbggAWXSVhjrHABH1ggt2Q520A-4,7956
|
|
10
|
+
recskit-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
recskit-0.1.0.dist-info/top_level.txt,sha256=mHFs_EZcE9kTNSmafjHmP6P3z7krLmLegrWXWv0X4io,8
|
|
12
|
+
recskit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recskit
|