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 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"]
@@ -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
+ )
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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